# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

> **Older releases (1.x).** This file covers the **2.x** line. The 1.x release history — including **1.6.0**, the mandatory security release you upgrade through — lives in **[CHANGELOG-1.x.md](CHANGELOG-1.x.md)**.

## [Unreleased]

## [2.0.0-alpha6] - 2026-07-10

Feature release. Adds the pieces to author an agent loop as a graph — a
provider-neutral reasoning contract in core with `reason` and `tool_invoke`
nodes — plus a `Stop` terminal node, in-editor trigger configuration, and
named dynamic ports on Prompt Template. Also repairs a gateway
branch-gating regression and two latent correctness bugs. No stored-format
changes from `2.0.0-alpha5`.

### Added

- **Graph-authored agent loops — `reason` and `tool_invoke` nodes over a provider-neutral core contract** (`flowdrop`, `flowdrop_node_processor`)
    - You can now build an agent's think→act loop directly on the canvas rather than inside a single opaque node: a `reason` node decides what the model wants next, a `tool_invoke` node runs the tools it chose, and a Conversation Buffer carries the history around the loop.
    - **Core reasoning boundary (`flowdrop`)** — new provider-neutral value objects (`DTO\Reason\{ReasonMessage,ReasonRequest,ReasonResult,ModelChoices}`) and a `Service\Reasoning\ChatReasonerInterface` service (`flowdrop.chat_reasoner`), defaulting to `NullChatReasoner`. Core's composer `require` stays free of `drupal/ai` — a `reason` node can be placed and validated with no AI backend installed; a backend module overrides the `flowdrop.chat_reasoner` service id to supply a concrete reasoner. `DTO\Reason\ToolPairing::heal()` repairs tool-call/tool-result pairing in a buffer (dangling call → synthetic "interrupted" result; drop orphan/duplicate results) so a crash-corrupted thread can't wedge the loop.
    - **`reason` node (agent step)** — one tool-aware LLM inference: given the conversation on the `messages` port and the tools wired via `tool_availability` edges, it asks the backend what the model wants next (tool calls or a final answer) and executes nothing. Heals the buffer before inference and no-ops on a trailing assistant turn (loop idempotency / interrupt-resume double-fire). Populates its own model/role config fields via the reasoner's `getModelChoices()`, so it needs no backend service wired into the node.
    - **`tool_invoke` node (loop body)** — runs a batch of model-chosen `tool_calls` (`[{name, args, tool_call_id}]`) against the wired tools via the `ScopedToolInvoker`, emitting one `tool`-role message per call. Exposure is topology: the wired `tool_availability` edges are the allow-list, and an unknown/unwired tool is a recoverable tool-error message, not a throw. Sits beside the existing single-named-tool `tool_caller` reference node rather than overloading it.
    - **Note:** with only `flowdrop` core installed the `reason` node uses `NullChatReasoner` and performs no inference; install a backend module that provides `flowdrop.chat_reasoner` to actually reason. Covered by `ToolPairingTest`, `ReasonTest` (fake reasoner), and `ToolInvokeTest`.
- **`Stop` terminal node — end a run cleanly from inside the flow** (`flowdrop`, `flowdrop_node_processor`, `flowdrop_runtime`, `flowdrop_stategraph`)
    - A new user-facing `Stop` node (logic category) ends the run when reached, optionally carrying a wired-in result payload and a configured reason. It is reached via the flow (keeps its trigger input) — a terminal, not a start, node.
    - Introduces the terminal/stop concept as the outbound dual of start nodes: `WorkflowStopException` (extends `\Exception` directly, **not** `FlowDropException`, so node-level error-routing catches never swallow it — mirroring the interrupt exception) and a `TerminalNodeProcessorInterface` marker. Every orchestrator (synchronous, frontier/job, StateGraph, async-by-delegation) now handles it: the throwing job is marked **COMPLETED** with the terminal payload (not FAILED), downstream jobs are abandoned, and the run completes. The sequential orchestrator stops precisely at the node; the frontier path keeps jobs already running in the current ready-batch and abandons the rest.
    - `WorkflowValidator` now emits a non-blocking **warning** when a node is wired after a terminal node (it can never execute). Terminal-ness is read from the plugin definition's class via `is_subclass_of` — no instantiation — preserving the validator's laziness contract.
- **Start/terminal node boundary markers** (`flowdrop`, `flowdrop_node_type`)
    - New `StartNodeProcessorInterface` marks node processors that begin a workflow (entry points). Since nothing upstream fires a start node, the metadata resolver no longer injects the reserved trigger-*input* port for any start node. `TriggerNodeProcessorInterface` now extends it (Trigger ⊂ Start), so every existing trigger inherits the behavior and a future non-trigger start node just implements the marker.
    - Symmetrically, the metadata resolver no longer injects the reserved trigger-*output* port for any `TerminalNodeProcessorInterface` — a terminal node ends the run when reached, so an outgoing flow edge is meaningless there (the editor now offers none, rather than relying only on the after-the-fact validator warning). Additive and BC-safe.
- **Prompt Template — named dynamic input ports** (`flowdrop_node_processor`)
    - The existing Prompt Template node gains `DynamicPortTrait`: authors can declare named input ports and reference each as a `{{port_name}}` placeholder, alongside the existing `variables` bag. A named port wins over a same-key bag entry; an unconnected declared port is filtered so it can't clobber a bag key; the whole bag stays reachable as `{{ variables.x }}`. Additive and BC-safe — with no ports declared the node behaves exactly as before.
- **Trigger configuration from the workflow editor** (`flowdrop_trigger`, `flowdrop_workflow`)
    - Triggers can now be configured inline on the canvas, not only via the standalone admin form. The schema endpoint returns the event-type-specific fields hydrated with the reserved config props plus a matching grouped uiSchema; trigger domain fields render inline while General/Execution/Ports collapse into fieldsets like any other node. On save, `flowdrop_workflow_presave` flushes the buffered `event_type`/`conditions` into the trigger-config entity and strips them from the stored node, so the entity stays the single persisted copy ("no drift by construction"); a config is created for a node configured inline before one existed. Event-specific output ports (user/form/cron) ship not-exposed by default, so a cron trigger no longer dangles five always-null form ports. Trigger processor `2.2.0` → `2.3.0`.

### Fixed

- **Gateway nodes no longer branch-gate their own data-output ports** (`flowdrop_pipeline`)
    - A source emitting `active_branches` was gating *every* one of its outgoing edges by branch membership, poisoning its data-output ports: a dual-mode node such as Confirmation emits both a `confirmed` value and a true/false branch, so its `confirmed → …` data edge was gated as a branch named "confirmed" (never in `["true"]`), skipping everything downstream. `isEdgeBranchActive()` now distinguishes data ports (name is a data-output key → flows unconditionally) from genuine branch ports (name absent from the output payload → subject to branch-membership gating). Regression cover added for both wiring patterns.
- **Confirmation node now declares the gateway marker** (`flowdrop_interrupt`)
    - `ConfirmationNode` is dual-mode (emits both a `confirmed` value and a true/false branch) but didn't declare `BranchingNodeProcessorInterface`, so the editor treated it as an ordinary node. It now surfaces as a gateway (capability badge + gateway visual-type default). Marker only — branch routing is still driven by the `active_branches` output, so there is no runtime change.
- **Reserved config-schema-only params are now gated on per-node-type opt-in** (`flowdrop_node_type`)
    - `deriveSchemas()` force-routed every reserved config-schema-only param (`dynamicInputs`, `dynamicOutputs`, `branches`) into the config schema unconditionally, so the editor showed dynamic ports / branches as configurable even where the node type didn't declare them — yet the runtime resolves those only when the entity marks the param `configurable:true`, so ports could be wired in the editor but never substitute at runtime. Exposure is now opt-in per node type; the editor agrees with the runtime. Every shipped consumer already declares the param, so no behavior change for them.
- **Trigger config lifecycle — clone, cleanup, and dependency** (`flowdrop_trigger`, `flowdrop_workflow`)
    - A duplicated workflow got a new ID but kept its source node IDs, so its trigger nodes computed a `trigger_config_id` with no backing entity — copied triggers were silently unconfigured. `flowdrop_workflow` now dispatches `WorkflowDuplicatedEvent` and `WorkflowDuplicateSubscriber` clones each source config onto the new workflow ID.
    - Trigger configs were orphaned when a workflow was deleted programmatically or a trigger node was removed from the canvas. `flowdrop_workflow_predelete` now deletes every trigger config for the workflow, and `flowdrop_workflow_update` deletes configs whose node is no longer in the saved graph.
    - A trigger config declared no dependency on its parent workflow, so it didn't travel on export/staging and config sync wouldn't remove it. `FlowDropTriggerConfig::calculateDependencies()` now declares a config dependency on the workflow.
- **Config-entity listings now paginate across the full result set** (`flowdrop_node_type`, `flowdrop_workflow`, `flowdrop_node_category`, `flowdrop_trigger`)
    - The node-type, workflow, node-category, and trigger-config list builders do in-memory filter/sort/paginate, but `ConfigEntityListBuilder` applied a query pager in `getEntityIds()` that sliced the result to the first page before that pipeline ran — capping the pager at one page and scoping sorting/filtering to page one. Each list builder now overrides `getEntityListQuery()` to drop the query pager so the full set reaches the in-memory pipeline.
- **Two latent correctness bugs repaired** (`flowdrop_runtime`)
    - `WorkflowStateManager::validateDependencies()` used `!$status === SKIPPED`, which binds as `(!$status) === 'skipped'` and is always false — the dependency-consistency guard never fired. Corrected to `$status !== SKIPPED`, with regression tests.
    - `ExecutionLogger` called `$params->toArray()` on a `ParameterBag` that only exposes `all()`/`jsonSerialize()` — a fatal on the node-execution hot path waiting for verbose logging to be enabled. Switched to `all()`.
- **Tighter entity type guards and a dead orchestrator argument** (`flowdrop_runtime`, `flowdrop_trigger`, `flowdrop_stategraph`, `flowdrop_job`, `flowdrop_node_type`)
    - Replaced generic `EntityInterface` access with proper `instanceof` narrowing across the checkpointer, trigger manager, trigger-config controller, and constraint validator, removing reliance on undefined-method/property calls. Also dropped a second argument `StateGraphOrchestrator` passed to the single-parameter `JobGenerationService::generateJobs()` (silently discarded), and removed dead null-coalesces on non-nullable expressions.

### Changed

- **`json_to_data` now has distinct strict/tolerant failure contracts and a trustworthy `success` flag** (`flowdrop_node_processor`)
    - Previously `success` was derived from the decoded *value* (`$result !== NULL`), not the parse outcome. In **strict** mode every failure path — invalid JSON, empty string, non-string, missing input — returned `[]`, so `success` was `true`; the only input that reported `false` was the *valid* JSON literal `null`. The two modes also disagreed on identical inputs (strict `[]`/`success=true` vs tolerant `null`/`success=false`), and tolerant `'null'` reported `parse_source='direct'` alongside `success=false`. The flag was effectively unusable — the bug stayed latent only because nothing wired the `success` port.
    - **Strict mode is now assert-or-fail:** a parse failure throws (a non-retryable `\RuntimeException`), so it surfaces as a routable error output — routed to the node's error edge where the author declared one, otherwise failing the run. Strict output is now `{data}` only (no `success`/`parse_source`).
    - **Tolerant mode is best-effort:** it never throws and reports `{data, success, parse_source}`, with `success` derived from `json_last_error()` and `data` unified to `null` on failure. `parse_source` is never set when `success` is `false`.
    - Valid non-container JSON (numbers, strings, booleans, and the literal `null`) is now **accepted** as data in both modes rather than treated as failure; the output schema's `data` type was broadened accordingly. **Note:** a strict-mode node that was unknowingly fed malformed JSON now fails loudly instead of silently emitting `[]` — switch it to tolerant mode or wire an error edge if the old behavior was relied upon.
- Bundled `@flowdrop/flowdrop` editor library upgraded `2.0.0-beta.6` → `2.0.0-beta.7` and the Drupal bundle rebuilt (`flowdrop_ui_components`).
- Development tooling only: the PHPStan production baseline is now empty (zero suppressions), a CI gate rejects any new production suppression, and the ddev PHP version moved to 8.4. No runtime behavior change.

## [2.0.0-alpha5] - 2026-07-07

Follow-up to `2.0.0-alpha4` refining how the new node-output serializability
guard fails. No API or stored-format changes from `2.0.0-alpha4`.

### Changed

- **Non-serializable node output now fails as a routable, non-retryable error instead of aborting the run** (`flowdrop_runtime`)
    - `2.0.0-alpha4` raised `NonSerializableOutputException` *after* the node's processor try block, so it propagated out of `NodeRuntimeService::executeNode()` and failed the entire run unconditionally — bypassing error-edge routing and retry, and (in StateGraph) leaving no checkpoint at the failing node.
    - The check now runs *inside* the processor try block, so the failure surfaces as a **non-retryable error output** and follows the node's normal failure path: routed to an error edge where the workflow author declared one, otherwise failing the run (the unchanged default). Retry is still withheld because a non-serializable output is deterministic. This keeps the failure loud and node-scoped while honoring author-declared error handling and preserving StateGraph resumability.

## [2.0.0-alpha4] - 2026-07-07

Hardening release. Node processors that return a value `json_encode` cannot
represent now fail loudly at execution time instead of silently corrupting to an
empty object once persisted. No API or stored-format changes from
`2.0.0-alpha3`.

### Added

- **Node output is now validated as JSON-serializable at execution time** (`flowdrop_runtime`)
    - Every execution engine (synchronous, asynchronous, StateGraph) persists node output through `json_encode` — job records and StateGraph checkpoints alike. `json_encode` does not throw on a value it cannot represent; it silently emits an empty object. A processor that returned a non-serializable value (a loaded entity, a `Url`, a `Markup` object, a closure, a resource) therefore had its output silently mangled to `{}` at the persistence boundary — surviving the in-memory synchronous path but corrupting to empty data once persisted, so a downstream node saw nothing and the loss was engine-dependent and silent.
    - `NodeRuntimeService` now validates the composed output at the single point all engines converge on, before it is persisted anywhere, and throws `NonSerializableOutputException` (a `DataFlowException`) naming the node, plugin, dotted path, and offending value type. The check mirrors `json_encode`'s own contract — scalars, null, arrays, `stdClass`, and `\JsonSerializable` are allowed; any other object or resource is rejected — rather than the Symfony/Drupal serializer, which would accept entities this pipeline cannot store.
    - The exception propagates to the orchestrator (logged as a node failure), turning previously-silent data loss into a loud, node-scoped error. **Note:** a workflow that was unknowingly relying on the old silent-`{}` behavior will now fail loudly — the fix is to reduce entities to a reference such as `{entity_type, id}` (and cast `Markup`/`Url` to strings) before returning them from a processor.

## [2.0.0-alpha3] - 2026-07-07

Bug-fix release. The central logging settings now govern all runtime execution
logging. No API or stored-format changes from `2.0.0-alpha2`.

### Fixed

- **Logging settings were ignored by runtime execution logging** (`flowdrop_runtime`, `flowdrop_stategraph`, `flowdrop_workflow_executor`)
    - The controls at `/admin/flowdrop/config/flowdrop` were only partially wired: `logging.log_to_watchdog` was never read by any code, and `logging.verbosity` was honored only inside `ExecutionLogger`. The ~130 direct `$logger->info()/debug()/notice()` calls across the execution engines (orchestrators, `DataFlowManager`, `ParameterResolver`, `StatusTracker`, the StateGraph engine, and the workflow executor) wrote to their channels regardless of either setting, so turning verbosity down — or disabling watchdog logging entirely — had no effect.
    - A logger-factory decorator (`FilteredLoggerChannelFactory`) now gates every write on the FlowDrop execution-engine channels (`flowdrop_runtime`, `flowdrop_stategraph`, `flowdrop_workflow_executor`) against the central settings, no matter how a service obtained its channel. Both toggles are absolute: `log_to_watchdog` off — or `silent` verbosity — silences everything including errors; `minimal` = errors only; `standard`/`verbose` = all but debug; `debug` = everything. All other channels are untouched.

### Changed

- Development tooling only: resolved all PHPCS and PHPStan violations across the modules and suppressed unresolvable `flowdrop_ai_provider` class references in CI (circular-dependency artifact). No runtime behavior change.

## [2.0.0-alpha2] - 2026-07-06

Bug-fix release closing two regressions introduced by the 2.0.0 node-metadata
slimming (the `data.metadata.node_type_id` anchor rename). No API or stored-format
changes from `2.0.0-alpha1`.

### Fixed

- **Lean execution path could not resolve node types** (`flowdrop_runtime`, `flowdrop`)
    - `SynchronousOrchestrator` handed `NodeRuntimeService::executeNode()` the executor-*plugin* id (e.g. `flowdrop_node_processor:chat_input`) where it expects the node-*type* entity id (`chat_input`). The runtime loads the `flowdrop_node_type` entity by that id, so every node failed with `Node type '<plugin-id>' not found` — breaking the no-persistence execution path (`executeWorkflow()`) for all workflows.
    - `NodeMapping` now carries the node-type entity id from the node's `node_type_id` anchor (mirroring the pipeline path) and `SynchronousOrchestrator` passes it to `executeNode()`; the trigger-node check still resolves via the plugin id. A missing anchor now fails loudly rather than silently substituting the plugin id.
- **Edge-free nodes were dropped from the execution graph** (`flowdrop_workflow`)
    - A node with no incoming *and* no outgoing edges (a single-node workflow, or a node not yet wired up) was classified as an orphan and excluded, so it never ran. `WorkflowCompiler` now treats an edge-free executable node as a standalone root and executes it. Non-executable processors (e.g. Note) are still excluded earlier via their marker interface, independent of topology.

## [2.0.0-alpha1] - 2026-07-02

> **⚠ Upgrade to the latest 1.6.x first — it is a mandatory security release.** 1.4.x and 1.5.x carry security fixes that first reached the stable line in 1.6.0, so the supported path into 2.0.0 is *via 1.6.x* (don't jump straight from 1.4.x/1.5.x).
>
> The entries below are the delta **from 1.6.0**. Everything a 1.6.0 site already has — its security fixes, timing nodes, permission split, and other features — lives in the **[1.6.0] section of [CHANGELOG-1.x.md](CHANGELOG-1.x.md)** and is not repeated here. Genuinely new in 2.0.0: three **BREAKING** changes (Drupal `^11.3`, slimmed node metadata, failed-node-fails-the-run), Workflow Doctor, node-failure error-port routing, signed bundle import/export, Conversation Buffer tool-calling messages, dangling-edge R8, four deprecated subsystems now removed, and the breaking completion of the HITL loop-replay fix.

### Security

- **HITL consent gate — resolutions are now bound to the job that raised them** (`flowdrop_runtime`, `flowdrop_stategraph`) — **BREAKING**
    - Completes the loop-replay fix that 1.6.0 shipped only half of (see the [1.6.0] Security entry in [CHANGELOG-1.x.md](CHANGELOG-1.x.md)).
    - The bug: in a cyclic StateGraph workflow with a confirmation/approval node in the loop body, the gate paused only on the **first** iteration — clones inherited an already-resolved `interrupt_id` and were routed to `resume()` instead of `process()`, silently auto-approving every later iteration with iteration 1's answer (and duplicating the trigger-gated consumer's side effects).
    - 1.6.0 shipped the *clone-hygiene* half: a loop-iteration clone strips the raising job's `interrupt_id` / `execution_time_us`.
    - 2.0.0 adds the structural half — **ownership at the point of consumption**: `ResolvedInterruptInterface` (`@api`) gains `getJobId()`, and `NodeRuntimeService::resolveResumeInterrupt()` honors a resolution only when its `job_id` matches the executing job. A missing/mismatched id falls through to `process()` — re-asking the question, the safe direction for a consent gate.
    - Closes the whole bug class: no producer of job metadata (clones, retries, copies) can reintroduce the bypass.
    - **BREAKING (fail-closed):** external `ResolvedInterruptInterface` implementations fatal at class load until they add `getJobId()` — no shim, since an implementation that can't say which job owns a resolution can't safely resume.
    - Pinned by `StateGraphInterruptScopingTest`.

### Added

- **Workflow Doctor — diagnose & repair stored workflows** (`flowdrop_workflow`)
    - A new admin "Doctor" inspects a *stored* workflow for problems — missing executor plugins, invalid `data.config`, dangling edges, unknown keys — and offers per-issue recovery through a two-step **review → confirm** form.
    - Surfaced at route `flowdrop.workflow.doctor`, a local-task tab on the workflow, and a "Doctor" operation on the workflow list — all gated by update access.
    - UI-agnostic spine: diagnostics are tagged services (`flowdrop_workflow.diagnostic`, collected via `!tagged_iterator`) implementing `WorkflowDiagnosticInterface`, returning `Finding` objects (each with `RemedyOption`s and a stable locator). `ValidatorDiagnostic` adapts the shared `WorkflowValidator` rules R1/R6/R7/R8 + unknown-key warnings without re-detecting them.
    - The client sends only a `RemedySelection`; the server rebuilds it into `WorkflowMutation` primitives, applies them through one `WorkflowMutator`, re-validates, and saves (closing the TOCTOU window). `applyRemedies()` persists partial fixes, so residual findings don't block.
    - `WorkflowDoctorInterface` (`diagnose` / `previewRemedies` / `applyRemedies`) is the shared entry point.
- **Dangling-edge handling: reject on write (validator R8), repair on read** (`flowdrop_workflow`)
    - Orphaned edges can only enter through hand-edited JSON, imports, or migrations (the editor removes a deleted node's edges atomically, and the swap flow drops incompatible edges). Two defenses close the gap:
    - **Reject on write** — new `WorkflowValidator` rule **R8** requires every edge endpoint to resolve to an existing node, else the save/import is refused (`R8_EDGE_SOURCE_MISSING` / `R8_EDGE_TARGET_MISSING`, located by `edge.{index}`). Runs in the shared `validate()` choke-point, covering both the REST API and the entity form.
    - **Repair on read** — `WorkflowDTO::fromArray()` now prunes edges whose source or target node is gone (was: dropped from the edge *index* but kept in the edge *list*). Pruned edges stay observable via `getDroppedEdges()` for a load-time notice or diagnostic.
- **Node failure handling — opt-in retry, first-class failure, and error-port routing** (`flowdrop`, `flowdrop_runtime`, `flowdrop_pipeline`, `flowdrop_stategraph`)
    - Lets workflow authors handle a node's failure from inside the flow, opt-in. **See the BREAKING entry below** — this is *not* fully transparent on upgrade.
    - **Retry** — a processor throwing the new `@api` `RetryableNodeProcessingException` (or any `@api` `RetryableExceptionInterface`, mirroring `InterruptExceptionInterface`) is re-run in place while retryable and the per-node budget remains. Attempts are **immediate — no delay or backoff** (fits optimistic-concurrency conflicts and transient locks; for failures that only clear after a wait — network timeouts, upstream 5xx, rate limiting — route the `error` port to a Timer/Delay node instead). Retry re-runs `process()` wholesale, so the node must be idempotent (documented on the interface).
    - **Error output port** — a new reserved `error` output port (`ReservedName::PORT_ERROR`, handle `{nodeId}-output-error`). An edge from it declares the node's error path: on terminal failure (after any retries) the engine marks the job `FAILED` + `error_routed`, emits `{error: {message, code, node_id, retryable}}` on that port, routes it to the handler, and the run continues — completing unless an *unhandled* failure occurs elsewhere. Success and error paths are mutually exclusive; a node reachable from both runs on whichever fires first (trigger-like OR-semantics, same stale-source guard as branch gating).
    - Handle classification lives in the shared `@api` `EdgePortClassifier` (`isErrorEdge`/`isReservedOutputPort`). Routing is implemented once in `AbstractOrchestratorBase` and shared by the StateGraph and synchronous-pipeline orchestrators (so the queue-backed async path inherits it).
- **Conversation Buffer: agent-loop (tool-calling) conversations** (`flowdrop_memory`)
    - The `conversation_buffer` node can now store a full tool-calling history, not just `{role, content}`.
    - New optional **`message`** input accepts a complete message object (or an array), normalized to the Message-DTO shape `{role, content, metadata, timestamp}` (matching `flowdrop_orchestration`'s `Message::toArray()`). An assistant turn's `tool_calls` and a `tool`-role turn's `tool_call_id` (top-level or under `metadata`) are preserved under `metadata` instead of being stripped — so a provider replaying the history sees paired tool_use/tool_result turns.
    - When `message` is connected, `role`/`content` are ignored (fully BC — existing role+content workflows unchanged). The `role` enum gains `tool`.
    - Empty-content rules match provider behavior: an assistant turn that only calls tools is kept; any other empty message is dropped; a `tool` turn with no `tool_call_id` fails loudly (`InvalidNodeConfigurationException`).
    - The sliding window (`max_messages`) is now **pair-safe**: once any message carries tool metadata, the cut moves to the first `user` turn at/after the boundary (or keeps the buffer whole), so a `tool_use` is never split from its `tool_result`; a tool-free buffer trims exactly as before.
    - `memory_read` also gains a `loop_back` input port, so it can be a StateGraph loopback target and re-read the buffer each iteration.
    - Lets agent-loop consumers retire bespoke conversation-append/read forks.
- **Signed workflow bundle transport — import/export** (`flowdrop`, `flowdrop_workflow`, `flowdrop_node_type`)
    - Workflows export as a portable JSON bundle envelope (`flowdrop.bundle/v1`) and import on another site — the transport layer beneath the workflow marketplace.
    - **Trust is detached-Ed25519:** a `flowdrop_trusted_publisher` config entity (admin UI under FlowDrop) registers a publisher's public key; `BundleVerifier` resolves a signature against an *enabled* publisher over canonical payload bytes (`BundleCanonicalizer`). An unknown/disabled publisher, a key-id mismatch, or a tampered payload all resolve as untrusted, never trusted (signing happens off-site; private keys never in this repo).
    - **Import gate chain:** format check → signature verify → capability manifest (a node referencing an uninstalled processor is reported as "install module X") → structural validation → conflict check (never overwrites an existing id) → `NodeTypeGenerator` mints node types for installed-but-unprovisioned plugins (additive; provider anti-spoofing enforced) → save.
    - Untrusted/unsigned bundles are blocked unless the caller holds **`import untrusted flowdrop_workflow`** and confirms; `import flowdrop_workflow` gates the endpoint.
    - Exposed over `@internal` REST endpoints and `drush flowdrop:workflow:export|import|diagnose` (CLI treats shell access as the trust gate via `--allow-untrusted`).
    - **Untrusted-input hardening:** request bodies are bounded by size (8 MB) and nesting depth (64) before decode/canonicalization; the workflow id and any generated node-type id are validated against the config machine-name pattern (and length) before storage; a missing or unsupported `format` is rejected up front.
    - The bundle subsystem's PHP classes are `@internal`; the `flowdrop_trusted_publisher` entity type and its config schema follow standard config/entity BC rules.

### Changed

- **Minimum Drupal core raised to `^11.3`** (all modules) — **BREAKING**
    - `core_version_requirement` in every `*.info.yml` and the `drupal/core` constraint in `composer.json` now require `^11.3` (was `^11.2`); the bundled `flowdrop_ui_components` build targets 11.3's module-loading behavior.
    - Update core to 11.3+ before installing 2.0.0. See [UPGRADE.md](UPGRADE.md#requirement-change-drupal-113).
- **Workflow node metadata slimmed to a single source of truth** (`flowdrop_workflow`, `flowdrop_node_type`, `flowdrop_pipeline`, `flowdrop_runtime`, `flowdrop_stategraph`, `flowdrop_chat`) — **BREAKING (stored format)**
    - Was: a saved node's `data.metadata` duplicated its node type (`executor_plugin`, `name`, `inputs`/`outputs` ports, config defaults, visual `type`) plus a node-level React Flow `type` — attacker-controllable over the API and prone to drift from the authoritative node type + plugin schema.
    - Now: nodes persist only the node-type anchor `data.metadata.node_type_id` (renamed from the ambiguous `data.metadata.id` — matches the engine's `node_type_id` vocabulary and stops colliding with the top-level node `id`) plus genuine per-instance input (`data.config`, `data.label`, `position`, the `data.extensions` bag).
    - `FlowDropWorkflow::preSave` strips the derived fields, transient canvas state (`selected`, `dragging`, `deletable`), and the redundant `data.nodeId`; the `measured` layout cache is kept. The API/editor re-injects derived fields on read (`NodeMetadataResolver::enrichNodes`); every execution/save/export/admin reader re-resolves them from the node type (new `resolveExecutorPlugin()`).
    - Migrated readers: `WorkflowNodeDTO` (via all three orchestrators), `JobGenerationService`, `SystemPromptBuilder`, `WorkflowValidator`, the schema controller/builder, the exposure form, and the editor/playground page controllers (`WorkflowController::openWorkflowEntity`, `PlaygroundController` — which had been shipping slim, port-less nodes to the editor canvas).
    - Save-time config validation (rule **R6** in the shared `WorkflowValidator`) now checks `data.config` per key against the plugin schema. The `flowdrop_workflow.node_metadata` schema is tightened to `{ node_type_id }` (dead `node_input`/`node_output` removed).
    - Runtime reads `node_type_id` only through the shared `NodeAnchor` helper, which asserts loudly on an un-migrated node rather than misrouting (no read-time fallback to `id`). The one rename seam, `slimNodesForStorage`, renames `metadata.id` → `node_type_id` on save, so an un-migrated workflow self-heals on its next save. `NodeMetadata::toArray()` now keys `node_type_id` (`fromArray()` reads either; the object property is unchanged).
    - Migration: `flowdrop_workflow_post_update_slim_node_identity` resaves every stored workflow. Config sync is exempt (1.x YAML warns under the tightened schema until re-saved/re-exported); an `@internal` admin form at `/admin/flowdrop/workflows/migrate-node-identity` re-runs it for config re-imported after the update. Bundle export stays self-describing (full metadata re-inflated, now carrying `node_type_id`; re-slimmed on import).
    - Transparent for UI authors; affects custom PHP that read derived metadata off the stored shape. See [UPGRADE.md](UPGRADE.md#stored-format-change-workflow-node-metadata-slimmed-to-its-source-of-truth).
- **A failed node now fails the run instead of silently completing; auto-retry is off by default** (`flowdrop_runtime`, `flowdrop_pipeline`) — **BREAKING (runtime behavior)**
    - Two long-standing behaviors changed, both surfaced by the new node-failure work above.
    - **First-class failure** — until 2.0.0 a processor that threw a `\RuntimeException` was swallowed by `NodeRuntimeService` into an error-status result and the job marked `COMPLETED`, so the run carried on masking the error and feeding empty/partial output downstream. Now a failing node fails its job: with an `error` edge wired the failure routes down it (run continues); with no error edge the job fails and the run fails.
    - **Retry is opt-in** — per-node retry budget now defaults to **0** (was an implicit **3**), authored via the injected reserved **`maxRetries`** config property (`ReservedName::MAX_RETRIES`) — a "Max retries" integer field `NodeMetadataResolver` adds to every executable node's config schema (like `instanceTitle`). Read at job generation into the job's `max_retries` field (snake_case tolerated). Engages only for failures a processor explicitly marks retryable. The `preSave` coercion that rewrote a stored `0` to `3` is removed; `getMaxRetriesForNode()` clamps negatives to 0.
    - Net effect on upgrade: a node that used to flake, get retried up to three times, then silently "succeed" will now fail the run on its first error unless you raise `max_retries` and/or wire an error edge.
    - No stored data changes and no database update — the change is only in how stored workflows execute. See [UPGRADE.md](UPGRADE.md#behavior-change-a-failed-node-now-fails-the-run-and-auto-retry-is-off-by-default).
- **Workflow dependencies declared as config dependencies on referenced node types** (`flowdrop_workflow`, `flowdrop_node_type`)
    - `FlowDropWorkflow::calculateDependencies()` now records a `config` dependency on each node type a workflow references (resolved from the `data.metadata.node_type_id` anchor via `entity_type.manager`), instead of re-deriving each node's executor-plugin provider as a `module` dependency.
    - The module dependency is carried transitively — every node type already declares a `module` dependency on its plugin's provider — so uninstalling that provider cascades through the node type to the workflow rather than vanishing out from under it.
    - Drops the two domain-service lookups the entity previously made (`flowdrop.node_processor_plugin_manager`, `NodeMetadataResolver`); the remaining `entity_type.manager` lookup is the accepted seam for `calculateDependencies()`. All shipped node-type and workflow `config/install` dependency blocks were regenerated.
    - **Behavior change:** a workflow now hard-depends (config level) on the node types it uses — deleting a referenced node type flags/cascades to the workflow (before, a missing provider failed only at runtime). No DB update; existing config slims on its next non-sync save.
- **Tool-exposed node schemas normalized to standard JSON Schema** (`flowdrop_runtime`)
    - When a node is exposed as a tool, the schema projected into the model-facing `input_schema` is now normalized so providers (Anthropic, OpenAI, …) accept it without consumer-side cleanup.
    - FlowDrop's relaxed dialect is rewritten: a per-property `required` boolean is lifted into (and merged with) the enclosing object's `required` array and removed from the property; the non-standard `type` values `mixed`/`any` are dropped (omitting `type` is the valid "any type" form). Nested object `properties` and array `items` normalize recursively; an already-standard schema is unchanged.
    - Runs in `ToolProjector::describe()` after parameter-pinning projection (new `ToolParameterScope::normalizeForModel()`); affects only the schema the model sees, not author config or validation.
- **Per-group icons on the injected config fieldsets** (`flowdrop_node_type`, `flowdrop_ui_components`)
    - The reserved-config fieldset groups now carry meaningful icons — General (cog), Execution (bolt), Ports (arrows-right-left) — threaded through `UiSchemaGenerator` into the emitted Group element and rendered with the filled header-bar fieldset styling from the rebuilt `@flowdrop/flowdrop` bundle.

### Removed

- **StateGraph ApprovalGate subsystem** (`flowdrop_stategraph`) — removed (deprecated in 1.6.0)
    - Gone: the `approval_gate` node plugin and the entire surface — `ApprovalGateInterface`, `ApprovalStatus`, `ApprovalGateHandler`, the `flowdrop_stategraph.approval_gate_handler` service, `ApprovalRequiredException`, and the three `ApprovalGate*Event` classes.
    - Use the `flowdrop_interrupt` HITL system instead: the `confirmation` (approve/reject) or `form_input` (approval with notes) nodes, interrupt entities, and `InterruptRequiredException`.
    - `StateGraphOrchestrator` still catches `InterruptExceptionInterface` (the modern path is unaffected); it no longer advertises the `approval_gates` capability. See <https://www.drupal.org/node/3593796>.
- **`SessionService::sendMessage()`** (`flowdrop_session`) — removed (deprecated in 1.6.0)
    - Use `SessionTurnService::executeTurn()` for a chat turn (post + execute, with the concurrent-turn guard and turn events), or `postMessage()` to append to the room without executing.
    - Its private scheduling helpers and the then-unused `@queue` / deferred-scheduler constructor dependencies were dropped with it. See <https://www.drupal.org/node/3593549>.
- **`InterruptManagerInterface::createInterrupt()` and `InterruptRequiredException::$interrupt`** (`flowdrop_interrupt`) — removed (deprecated in 1.4.0)
    - Create interrupts by throwing `InterruptRequiredException` with an `InterruptMessageInterface`, or — for synchronous creation — build an `InterruptCreationData` and call `InterruptFactoryInterface::create()`.
    - Read the entity via `getInterrupt()` and the message via `getInterruptMessage()`. See <https://www.drupal.org/project/flowdrop/issues/3591539>.
- **`AgentExecutorInterface` and `AgentTraceInterface`** (`flowdrop_runtime`) — removed (deprecated in 1.6.0)
    - Also gone: `SynchronousOrchestrator`'s optional `$agentExecutor` constructor argument and its `executeAgentNode()` dispatch path.
    - Agent nodes now execute through the standard node-execution path: implement `ToolsAwareInterface` on the processor — tools wired via `tool_availability` edges are injected with `setTools()` and the result is returned from `process()` (no separate executor, `DependencyGraph`, or `WorkflowDTO` in plugin code).
    - No production code wired an executor, so this is a no-op for existing workflows. See <https://www.drupal.org/node/3593582>.
- **Legacy `PipelineStorageInterface` orchestrator constructor argument** (`flowdrop_runtime`) — removed (deprecated in 1.4.0)
    - The first argument of `AbstractOrchestratorBase` (and its `SynchronousOrchestrator`, `AsynchronousOrchestrator`, `SynchronousPipelineOrchestrator`, and `flowdrop_stategraph` `StateGraphOrchestrator` subclasses) no longer accepts a `PipelineStorageInterface`; pass a `PipelineStorageRegistryInterface`.
    - The internal `LegacyPipelineStorageRegistryAdapter` shim that wrapped a single storage as a registry is deleted. All shipped service definitions already inject the registry. See <https://www.drupal.org/project/flowdrop/issues/1>.

### Fixed

- **Shipped config dependency blocks were stale, mis-homed, or missing** (`flowdrop_node_type`, `flowdrop_node_processor`, `flowdrop_stategraph`, `flowdrop_chat`)
    - Config dependencies are a snapshot recomputed only on a non-sync save and imported verbatim by config sync, so 58 shipped node-type install files had carried empty `dependencies: {  }` blocks since before per-node-type provider tracking existed — declaring no module dependency despite using namespaced executor plugins.
    - Three latent install/runtime bugs fixed: (1) the `foreach` node type shipped under `flowdrop_node_processor` but its executor plugin (`flowdrop_stategraph:foreach`) lives in `flowdrop_stategraph` — moved to `flowdrop_stategraph/config/install`, since `flowdrop_node_processor` neither can nor should depend on the orchestration layer; (2) `flowdrop_chat` now declares its dependency on `flowdrop_node_processor`, which its shipped chat workflows use (`text_input`, `text_output`, `data_shaper`, `prompt_template`) but never declared — a missing module dep that failed only at runtime; (3) every shipped node-type and workflow `config/install` dependency block was regenerated from a fresh recalculation.
    - A new `ConfigDependencyIntegrityTest` installs the node-processor config and asserts no stored node-type dependency drifts from a fresh `calculateDependencies()`, guarding against recurrence.
- **Multi-source input ports resolve to the latest executor** (`flowdrop_runtime`, `flowdrop_stategraph`)
    - When several edges target one non-`multiple` input port the engine keeps a single value (never an aggregated array) — and that value must come from the most recently executed source, so resolution flows naturally into StateGraph loop order and keeps the error-recovery alternate path easy to reason about.
    - The bug: all four input-gathering paths sorted edges latest-execution-order first but then **overwrote** as they iterated, so the *earliest* executor's value won — the opposite of the documented intent. `JobExecutionWorker::buildRuntimeInputData()`, `AbstractOrchestratorBase::buildRuntimeInputData()`, and `SynchronousOrchestrator::resolveNodeInputs()` now keep the first value written (the latest executor) and ignore later collisions, with a corrected warning.
    - `StateGraphOrchestrator` (the loop path) never stamped `execution_order` on completion and resolved by raw edge order; it now stamps it (via `updateJobExecutionMetadata()`, which also sets the existing `execution_time_us`) and applies the same last-executor-wins sort.
    - Pinned by `MultiSourcePortResolutionTest`, `SynchronousOrchestratorMultiSourceInputTest`, and `StateGraphMultiSourcePortResolutionTest`.
- **Duplicate workflow CRUD routes removed; entity-generated routes win** (`flowdrop_workflow`)
    - The entity type's link templates + `AdminHtmlRouteProvider` already generate `entity.flowdrop_workflow.{collection,add_form,edit_form,delete_form}`, but the module also declared `flowdrop.workflows`/`.add`/`.edit`/`.delete` manually at the same paths — two routes per path collided, the entity-generated ones won every match, and the manual routes were left dead.
    - Knock-on: the "Edit Settings" local task pointed at the dead `flowdrop.workflow.edit`, so no tabs rendered on the workflow edit page.
    - The four redundant manual routes are removed and every reference (menu / action / task links, `MigrateNodeIdentityForm`, `WorkflowFilterForm`, and the cross-module call sites in `flowdrop_runtime` / `flowdrop_pipeline` / `flowdrop_trigger`) is repointed to the `entity.*` names.
    - The collection now follows the entity `admin_permission` (`administer flowdrop_workflow`) — admin-only, which suits a config entity.
