> ## Documentation Index
> Fetch the complete documentation index at: https://yukta.infenia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Remediation plan

> Prioritised plan for the core bugs, security findings and enhancements found by re-verifying the backlog, with decisions needed and a suggested PR order.

<Note>
  Internal plan, restricted to project members. Back to the [internal index](/internal).
</Note>

Source: re-verification of the core backlog (now the [bugs](/internal/bugs) and [improvements](/internal/improvements#core) pages) against `main@e8d62722` plus a fresh sweep of core and the
web/mcp edges (2026-10-13). **The sweep was static reading only; nothing below was reproduced or run.**
`Conf` says how sure the finding is: `read` = the cited lines were read and show the behaviour,
`plausible` = follows from documented framework behaviour, write the failing test first, `inferred` =
deduced from code paths, unconfirmed.

**Status:** B28, B31 and B34 (memory bounds) are fixed: the default execution log trims like the file log, the idempotency cache and the status history are capped. S3-S8, S10-S13, B4 and B35 are fixed by the security hardening PR (`fix/security-hardening`): key checks on the decoded path (400 for `;`, encoded separators and dot segments), MCP uses the REST keys, a Host/Origin filter for keyless loopback servers, a protected actuator, refusal to start on a non-loopback address without a key, process execution off unless a list or `yukta.process.allow-any=true` is set, an expression method allow-list, a script watchdog (`yukta.script.timeout-ms`, default 10 s; no memory limit), `${context.x}` limited to plain values outside `yukta.*`, an execution id check in the file log reader, 1 MiB request bodies (`YUKTA_LIMITS_MAX_BODY`, 413), reviewed and audited limit/priority changes, signals refused on the raw node command. F3 (hashed and rotating keys, session-limited keys that also cover execution routes, MCP roles per tool, MCP actor) and F5 (gateway tool allow-list, argument size cap and deny patterns `yukta.mcp-client.*`; `LLM_CALL` prompt and answer filters `yukta.llm.*`) are done, with a per-tool approval option (`requireApproval`) and a Control page view of who did what (`GET /api/control/audit`). Scripts also stop after `yukta.script.max-statements`; a heap limit is not available on this runtime.

New IDs continue the backlog numbering (`S` security, `B` bug). The sweep's own labels (N1-N14) are
kept in brackets. IDs `S5-S13` and `B28-B33` were checked against `core/docs` and the module guides:
no collisions (checked on `main` only, not on unmerged branches).

Effort: **S** up to half a day, **M** 1-3 days, **L** a week or more. Sev: C/H/M/L.

## Who can exploit what (read before ranking)

| Attacker | Findings that apply |
| - | - |
| Anyone who can reach the port (non-loopback bind, as the Docker image does) | S5, S6, S8, S4 through S6 (command execution), B28/B31/B34/B35 (memory growth, see [the performance plan](/internal/plans/performance)) |
| A web page in the operator's browser (loopback bind) | S7, and S5/S6 if no key is set |
| A holder of a low-role key (VIEWER/OPERATOR) | S10 (raw node command, limits, priority), F3 gaps (no MCP roles, no per-session scoping) |
| An untrusted workflow author | S4, S11, S12, S13, F5; with such authors treat S11/S12 as **High** and run `PROCESS_EXECUTOR` disabled |

**Decision (2026-10-13): workflow authors are trusted today and may not be later.** Severities in the
tables assume trusted authors, so S11/S12/S13/F5 stay where they are in the order below. They become
a release gate for the day untrusted authors are allowed:

* [x] S4 deny-by-default for `PROCESS_EXECUTOR` shipped (PR-3)
* [x] S11 expression method allow-list, S12 script statement limit and watchdog, S13 context key allow-list
* [x] F5 gateway-level tool allow-list and prompt filters; F3 per-session scoping (an author must not reach another session)
* [ ] B35 size limits and B34/B28/B31/B21 memory bounds (an author can otherwise exhaust memory)
* [ ] Re-rate S11/S12/S13 as High and move them from P2 to P1

Until then do not expose workflow upload to anyone outside the trusted group.

## Decisions needed from you (they gate effort more than code size does)

1. **S4 deny-by-default for `PROCESS_EXECUTOR`** (done, `yukta.process.allow-any=true` is the migration switch): breaking for anyone relying on the empty list. Ship in
   which release, and with a migration switch?
2. **S6 refuse to start on a non-loopback bind with no API key**: breaking for current Docker users, who
   bind to all addresses. Refuse, or only log a loud warning?
3. **B4 / B25 plugin-api changes** (new sealed command types, an actor field on status events): public
   API change. Is a break acceptable now, or do they wait for the next major?
4. **B21 retention policy** for session state: evict (and serve history from the log) or keep everything?
5. **S3**: fix the unused file plugin-log package, or delete it?
6. **Workflow authors** (decided: trusted now, untrusted possible later): the gate list above applies before that changes.
7. **Size limits** (B35 in [the performance plan](/internal/plans/performance)): which maximum start payload and definition size?

## P0: security exposure, small fixes

Suggested as one "edge hardening" PR (S5-S8) plus one core PR (S9, B28, R1, B31, B32). The memory
bounds B28, B31, B34 and B35 are also denial-of-service fixes; their performance side is in
[the performance plan](/internal/plans/performance).

| ID | Sev | Eff | Conf | Evidence | Issue | Fix | Test to add | Brk |
| - | - | - | - | - | - | - | - | - |
| S5 \[N1] (done) | H | S | plausible | `web/.../filter/ApiKeyFilter.java:133` (`getPath().value().startsWith("/api/")`); `mcp/.../McpEndpointFilter.java:47-49` (`equals` on the raw path) | The key check looks at the raw request path, while routing uses the decoded path with path parameters removed. A path that differs only in encoding or parameters may skip the filter and still reach the controller. | Decide on the decoded, parameter-free path elements, or reject a path containing `;` or `%` | WebTestClient: with a key set, send an encoded or parameterised form of an API path without a key and expect 401. Write it before the fix; if it passes today, downgrade the finding | no |
| S6 \[N2] (done) | H | S | read | `McpEndpointFilter.java:41` (`yukta.mcp.api-key`, default empty; blank means open, `:62-64`); `boot/.../application.yaml:5-7` (default bind 127.0.0.1) and `Dockerfile:54` (`YUKTA_SERVER_ADDRESS=0.0.0.0`) were read; the yaml comment tells operators to set only `yukta.api-key` | `/mcp` has its own key. An operator who sets only `yukta.api-key` leaves `/mcp` open, with start, control and signal tools. Combined with S4 that is command execution. Only reachable beyond loopback (`web/CLAUDE.md`: loopback unless `YUKTA_SERVER_ADDRESS`; the Docker image sets it). | `/mcp` falls back to `yukta.api-key`; see decision 2 | `@SpringBootTest`: `yukta.api-key` set and no MCP key, `/mcp` without a key is 401 | mild |
| S7 \[N3] (done) | H | S | plausible | No Host/Origin filter in `web`; query-parameter POSTs in `WorkflowController.java:97`, `ExecutionControlController.java:304,358` | A web page in the operator's browser can send simple cross-origin POSTs to the loopback server (stop, signal, limits); DNS rebinding can also read the event stream. | WebFilter with a Host allow-list; reject a non-GET whose `Origin` is present and not the server's | A POST with a foreign `Origin` and one with a foreign `Host` are rejected; same-origin UI calls still pass (`ui` e2e) | no |
| S8 \[N10] (done) | M | S | read (config) | `ApiKeyFilter.java:133` covers `/api/` only; `application-dev.yaml:8` exposes `health,info,metrics,loggers,mappings,threaddump` (read); that dev is the default profile is from the sweep | Actuator endpoints bypass the key even when one is set. | Key-check `/actuator/**` except `health`, or a separate management port | With a key set, `/actuator/loggers` without it is 401 | no |
| S9 \[N5] (done) | M | S | read | `ExecutionResumer.java:119` (`"Workflow no longer exists: " + started`) | `started` is the log event record whose `toString` includes the start payload; the 404 text reaches the client. | Use session and workflow ids only | Resume of a run whose workflow was deleted: the message holds no payload value | no |
| S4 (done: empty list denies unless `yukta.process.allow-any=true`) | H | S | read | `plugins/processors/process-executor/.../ProcessExecutorGateway.java:86,92-93` | Empty `yukta.process.allowed-commands` allows any executable. | Deny when empty plus an explicit enable switch (decision 1) | Empty list refuses; listed command runs; shell refused | **yes** |
| ~~B28~~ fixed \[N4] | M | S | read | `core/.../service/log/InMemoryExecutionLogStore.java:23-27` (`CopyOnWriteArrayList`, `replaceAll` only at startup) | The default log store never trims: every start payload and event stays, memory grows and each append copies the list. | Bounded structure, trim to `retained-runs` as `JsonlExecutionLogStore` compaction does, never dropping a run without an end | Append more than `retained-runs` runs and check the size is bounded and an unfinished run survives | no |
| R1 (done) | M | S-M | read (agent) | `RestartCommandProcessor.java:127,139-146`; `RestartFromNodeCommandProcessor.java:92,140`; `WorkflowOrchestrator.java:331` (`rerun` passes no admission); compare `WorkflowStarter.java:176-186` | Restart checks the limit, then acquires later, so two restarts can exceed a limit by one (the race #340 fixed for starts). | `scheduler.acquire` first, pass the admission to `execute`/`rerun`, `giveBack` on early failure | Two concurrent restarts with one free slot: exactly one proceeds, the other gets 429 | no |
| ~~B31~~ fixed \[N12] | L | S | read (agent) | `core/.../control/gateway/StartIdempotency.java` (Caffeine with `expireAfterWrite` only) | Unique keys per request keep one cached entry each for the whole TTL. | Add a `maximumSize` | Insert beyond the cap, size stays bounded | no |
| B32 \[N13] (done) | M | S | read (agent) | `WorkflowOrchestrator.java:235` (`Map.of()` into `rerun`), `:300` (archived and logged) | A restart-from-node records an empty payload, so a later resume or restart of that run re-fires triggers with `{}` and loses `yukta.priority`. | Pass the archived payload | Restart from a node, then resume it: the trigger payload equals the original | no |

## P1: control-plane integrity and correctness

| ID | Sev | Eff | Conf | Evidence | Issue | Fix | Test to add | Brk | Depends |
| - | - | - | - | - | - | - | - | - | - |
| S10 \[N6] + B4 (done) | M | M | read (agent) | `DefaultControlBusGateway.java:207-215,576-608`; `ControlBusService.java:395-397`; `ControlBusController.java:77,90-91`; `WaitForSignalProcessor.java:119-137`; review and audit only in `DirectiveDispatcher.java:132-148` | Before the fix: the raw node command was keyed by workflow and node, neither reviewed nor audited, and could release any approval wait; limits and priority changes were logged but not reviewed. Now all of them are reviewed and audited. | New sealed `SetLimits`/`SetPriority` commands through `DirectiveDispatcher`; signals only through `SignalNodeCommand`, or remove the raw endpoint | A reviewer that refuses everything also blocks limits, priority and a raw signal; each leaves a `CONTROL_REJECTED` | **yes** | decision 3 |
| B29 \[N9] (done) | M | S | read (agent) | `core/.../control/CommandSequencer.java:31-41`; `DirectiveDispatcher.java:132-148` | When a waiting command's caller cancels, its slot is released at once, so a later command can run next to the one still in progress; the cancel is not audited. | Chain the release behind `previous`; record the cancel as an outcome | Three queued commands, cancel the second: third still waits for the first | no | |
| B27 (done: guard) | M | S (guard), M (split) | read (agent) | `DefaultTaskTrackerService.java:843-862` (1066 lines), `WorkflowState.java:176-178` | No forward-only guard on run status; a second terminal event finalizes again and appends `finished` twice. | Return early when already terminal; extract history and cleanup projection later | Two terminal events: one `finished` log entry | no | |
| B33 \[F6] (done) | M | S-M | inferred | `WorkflowOrchestrator.java:475-485` (`doFinally` unregisters, no final status) | A cancelled sub-workflow child may never get a final status. | Test first; then finish the run as cancelled | Cancel the parent node mid-child: the child ends with a terminal status | no | |
| B26 (done) | L | S | read (agent) | `WorkflowPreparator.java:81-91,136-155`; `WorkflowValidator.java:310,396` vs `TerminalNodeAssemblerStrategy.java:69`; `StreamTopologyDecorator.java:303,306-318` | Edges duplicated when prepare is subscribed twice; two different terminal checks; a failed checkpoint save fails the node but a failed end marker does not. | `Mono.defer`; one terminal check; one error policy | Subscribe twice and compare edge counts; a plugin that is a terminal by category but not by type is rejected consistently | no | |
| S3 (done) | M | S | read (agent) | `logging/impl/file/FileSystemPluginLogReader.java:85`, `FileSystemPluginLogWriter.java:107-110`; no bean references them | The file log reader joins an unvalidated id into a path; the writer can overwrite on a first-write race. Dead code today. | Validate the id and always append, or delete the package (decision 5) | A traversal-shaped id is refused | no | decision 5 |

## P2: resource limits and design-level work

| ID | Sev | Eff | Conf | Evidence | Issue | Fix | Brk |
| - | - | - | - | - | - | - | - |
| S11 \[N7] (done) | M | M | read (agent) | `plugin-api/.../util/SpelUtils.java` (`withInstanceMethods()`) | Expressions can call any public instance method, including allocation-heavy ones, on strings and payload objects; one expression can exhaust memory. | Method allow-list through a custom resolver | mild |
| S12 \[N8] (done) | M | M | read (agent) | `plugins/processors/internal/script-js/.../GraalJsScriptEngine.java:63-76`, `MapperProcessor.java:354` | A script that never ends holds its thread; node timeout cancels the subscription but not the script context. | Statement limit and a watchdog that closes the context | no |
| B16 (done: fail fast) | M | M-L | read (agent) | `StreamTopologyDecorator.java:251-263`, `NodeBroadcast.java:64-69` | A branch that stops reading stalls its siblings until the idle timeout. | Propagate a downstream cancel-with-error to the run | no |
| F6 rest (done) | M | M | read (agent) | `SubWorkflowRunner.java`, `ExecutionScheduler.borrowed()` | A child asked for its own slot while the parent held one. | Done: the child borrows the parent's slot; the child runs inside the parent node, so cancelling the node cancels it (CANCELLED). Left: an index of parent and child runs (only the start payload carries the link). | no |
| B25 (not a defect: a pause publishes no event) | L | M | read (agent) | `SupervisorHost.java:181-187`; `ExecutionEvent.java:41,65` | Status events carry no actor, so a supervisor sees the pause it caused. | Actor field on status events; filter in the host | **yes** |
| B21 (done: count) | L | S / M | read (agent) | `DefaultTaskTrackerService.evictOldSessions` | Session keys were never removed. | Done: `yukta.tracker.max-sessions` evicts the oldest session without a running run. Left: a time-based policy (decision 4). | no |
| B30 \[N11] (latent: nothing emits heartbeats yet) | L | M | read (agent) | `ControlBusService.compositeKey:104`, `ControlHeartbeatHandler`, `ActivePluginRegistry` | Heartbeats, statistics and plugin-level commands are keyed by workflow and node only, so two sessions or runs with equal ids collide. | Include the session or execution id | no |
| S13 \[N14] (done) | L | S | read (agent) | `core/.../util/VariableResolver.java:182,250` | `${context.X}` can read any Reactor context key. | Allow-list the keys | mild |
| B19 rest (done) | L | S | read (agent) | `SubWorkflowRunner.java` | The sub-workflow start path archived on the caller's thread. | Done: `subscribeOn(boundedElastic())`. | no |

## P3: enhancements (statuses re-verified against code)

| ID | Eff | Still open | Prerequisite |
| - | - | - | - |
| F10 | L | Node checkpoint store in PostgreSQL; a CI job running the PostgreSQL acceptance test (it is skipped in CI today); native-image hints for R2DBC and Flyway; pool and health | none; unblocks HA and durable waits |
| F2 | M | Done: queued early signals, a list of open waits (REST, MCP, workflow page). Left: durable waits (needs the message id to survive checkpoint replay, and F10) | F10 for a database backend |
| F3 (done) | M | none; shipped: MCP roles and actor, per-session scoping, hashed and rotated keys, audit view | S5, S6 first |
| F9 | S-M | Done: MCP version tools, diff, versions panel. Left: notes on a version (three stores), pinning a run to a version | none |
| F4 | M | Done: per-provider rate limit, `llm.budget.exceeded` metric, `budget.hardStop`. Left: a budget across LLM\_CALL and MCP\_TOOL\_CALL, per agent, token and cost meters | none |
| F5 (done) | S-M | none; shipped: `deniedTools`, gateway-level allow-list, prompt filters | none |
| F7 | M | `KV_STORE` durability, session-memory TTL | F10 |
| F8 | S | Done: `tools/list` pagination, tool-role turns. Left: an example agent-loop workflow, streaming | none |
| F11 | L | Remote workers: design only, open decisions in [the remote workers plan](/internal/plans/remote-workers) | F10 |
| F12 | n/a | On demand. MDC today carries only the request id | none |

## Suggested order

1. **PR-1** S5 + S6 + S7 + S8: edge hardening (one theme, web and mcp).
2. **PR-2** S9 + B28 + R1 + B31 + B32 + B34 + B35: small core fixes and memory bounds.
3. **PR-3** S4 with a release note (decision 1).
4. **PR-4** S10 + B4: plugin-api command types (decision 3).
5. **PR-5** B29 + B27 guard + B33 + B26 + S3.
6. P2 items one at a time, then P3 by product priority.

Performance work: [the performance plan](/internal/plans/performance).

Run the narrowest tests per `CLAUDE.md` tiers; each PR ends with `spotlessApply check` once.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.