> For the complete documentation index, see [llms.txt](https://docs.labs.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.labs.ai/reference/changelog/2026-09.md).

# September 2026

> Archived entries for September 2026, newest first. For recent work see [the live changelog](/reference/changelog.md).

***

## 🧹 chore(ci): time limits on the E2E jobs and their browser install (2026-09-30)

**Repo:** EDDI (`chore/ci-playwright-timeouts`)

### Why

On PR 890 the Auth E2E (Keycloak) job sat in "Install Playwright browsers" for over an hour and a half; the tests never started. Other runs that day passed, so it was a stalled download on one runner, not a code problem. Nothing in [`ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml) bounded it, so a stall like that runs until GitHub's 6-hour default while the PR shows the check as pending.

### What changed

* `timeout-minutes: 10` on each of the three `npx playwright install --with-deps chromium` steps. A normal install takes 20 seconds to 5 minutes.
* Job limits: `UI Manager E2E (MSW)` 20 minutes, `Backend E2E` 20, and `Auth E2E (Keycloak)` 15. Recent green runs took about 5, 2 to 6, and 2 minutes.

A timed-out step fails the job, and re-running it is the fix. No automatic retry was added: interrupting `--with-deps` part-way can leave apt holding its lock, so a second attempt could fail for a different reason.

***

## fix(migration): identifiers are no longer held back as credentials by the legacy-properties migration (2026-09-30)

**Repo:** EDDI (`fix/650-credential-chatui`)

### Why

On a 5.5.1 database, `PropertiesMigrationService` held back five `createdProgram` properties whose value is `{courseId: <a 17-character alphanumeric id>}` and logged them as credentials. The rule that fired is the entropy check in `SecretScrubber.scrubTextValue` (check 3): a key-like string of at least 14 characters scoring over 3.5 bits per character is redacted unless its field is one of the structural names. A random 17-character id scores about 4.1, and `courseId` is not structural. The values stayed in `properties_migrated_v6`, but the users lost those memories.

### What changed

* [`PropertiesMigrationService`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/mongo/PropertiesMigrationService.java): the copy of the value handed to the scrubber (which already replaced BSON ObjectIds) now also replaces an **identifier-shaped** string (`[A-Za-z0-9_-]{1,128}`) under an **identifier-named** field — `id`, or a name whose last word is `id`/`ids` (`courseId`, `course_id`, `courseID`, `courseIds`) — with a placeholder. Names qualified by a credential word (`sessionId`, `accessKeyId`, `tokenId`, `apiId`) get no exemption.
* A new **known-format** check holds back any key whose value contains, at any depth, a JWT, a pasted `Bearer`/`Basic` value, or a provider key prefix (`sk-`, Stripe, Slack, GitHub, GitLab, AWS, Google, Hugging Face). It closes a gap the entropy rule never covered: `"Bearer …"` has a space, so the scrubber's whole-value pattern did not match it. An identifier-named field gets no exemption for these.
* The credential-name rules are unchanged, and the scrubber itself is untouched: the narrowing applies to this migration only, so export scrubbing is exactly as strict as before.

### Tests

`PropertiesMigrationServiceTest$IdentifiersAreNotCredentials`: the id migrates (with a precondition test proving the scrubber alone still flags it); name variants migrate; `token`, `apiKey`, a JWT, a `Bearer` value, an `sk-` key, a JWT under `courseId`, a high-entropy value under a neutral key and under `sessionId` are all held back and logged by key name only; `userInfo` is still skipped. Name- and format-rule cases use zero-entropy values so entropy cannot catch them for the wrong reason. Mutation-checked.

### Not changed

The export scrubber has the same false positive: any identifier of 14+ random characters under a non-structural field name is redacted on export. Loosening it globally is not shown to be safe everywhere, so it is left as it is.

***

## fix(chat): a user holding only eddi-user sees the agent's name (2026-09-30)

**Repo:** EDDI (`fix/650-credential-chatui`)

### Why

The Chat UI read the agent's name from `GET /descriptorstore/descriptors/{id}/simple`, which is `@RolesAllowed({"eddi-admin", "eddi-editor"})`. A chat user holding only `eddi-user` got a 403; the failure was swallowed, and the header showed only the logo — never the name of the agent they were talking to.

### What changed

* **The conversation read carries the name.** `SimpleConversationMemorySnapshot` gains a nullable `agentName`, which [`RestAgentEngine.readConversation`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java) sets after its ownership check, through the new [`AgentDisplayNameResolver`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/AgentDisplayNameResolver.java). The managed-conversation load goes through the same method. No endpoint was added and no role was widened.
* **Only the name, only for a user of the agent.** The resolver checks the caller's `USE` access to the agent (the gate `POST /agents/{agentId}/start` applies) before reading the descriptor of the conversation's agent version, and returns the name alone — not the description, owner, grants or configuration. Any failure answers `null`; the read itself never fails over a name.
* **Chat UI** ([`ChatWidget.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/ChatWidget.tsx)) takes the name from the snapshot and no longer calls the descriptor store; `fetchAgentDescriptor` is removed. With no name the header shows the logo, titled with the configured `title`.

### Tests

`AgentDisplayNameResolverTest` (name present for a `USE` caller; absent and no descriptor read without `USE`; absent on a store failure or blank name; description never serialised; `eddi-user` admitted by `readConversation` and by neither `IRestDocumentDescriptorStore` nor `IRestAgentStore`). Chat UI: name from the snapshot with the descriptor store answering 403, fallback to logo and title, blank name ignored. Mutation-checked.

***

## ⬆️ build(deps): langchain4j 1.20.2 and the safe half of the minor-and-patch group, for 6.5.0 (2026-09-30)

**Repo:** EDDI (`fix/6.5-release-readiness`)

### What changed

* **Quarkus** `3.39.5` → `3.40.1`. 3.40 is the new LTS (12 months of support) and continues 3.39: its migration guide is empty, and 3.40.0 and 3.40.1 contain only bug fixes (none in OIDC, REST, MongoDB, Qute or Micrometer). `quarkus-mcp-server` stays on `2.0.1`, the version the 3.40 platform itself ships. Verified with a clean compile, the full unit suite and the 5.x first-boot rehearsal on the 3.40.1 build.
* **langchain4j** `1.20.0` → `1.20.2`, and the beta line `1.20.0-beta30` → `1.20.2-beta30`. Every module EDDI pins was checked on Maven Central at the new version before the bump.
* **One exception:** `langchain4j-community-oci-genai` (Oracle GenAI) has no `1.20.2-beta30`; its newest release is `1.20.0-beta30`. It now pins its own property, `langchain4j-community-oci.version`. `BuildQualityGatesTest` allowed only the two shared properties, because a third can drift and fail at runtime with `NoSuchMethodError`. It now names this artifact as the single sanctioned exception, bounded twice by a new test:

  * only a patch-level lag on the core's major.minor line is allowed;
  * the exception **expires**: once the property equals the beta line (which Dependabot's langchain4j group will propose when a newer OCI release appears), the build fails until the artifact rejoins `${langchain4j-beta.version}` and the entry and property are removed.

  All four ways of breaking it were mutation-checked, each caught by a named test:

  1. the property caught up with the beta line;
  2. a minor-line drift;
  3. the exception entry removed;
  4. another artifact using the OCI property.

  The builder tests pass on the mixed versions (45/45 `LanguageModelBuildersTest`).
* From Dependabot's minor-and-patch group (#913), everything except reactor-netty:
  * Jackson `2.22.2` → `2.22.3` (core, databind, dataformat-csv, dataformat-xml);
  * commons-lang3 `3.20.0` → `3.21.0`;
  * maven-compiler-plugin `3.15.0` → `3.16.0`;
  * maven-resources-plugin `3.3.1` → `3.5.0`;
  * maven-clean-plugin `3.2.0` → `3.5.0`.
* **reactor-netty-http stays on 1.2.18.** #913 moved it to 1.3.7, which is built against Netty 4.2 while Quarkus manages 4.1. `ReactorNettyNettyCompatibilityTest` failed on it with the `NoClassDefFoundError` the pin's comment predicts, and since it shared a group PR with everything else, nothing in that group could merge. `.github/dependabot.yml` now ignores reactor-netty minor and major updates, with the same reason, until Quarkus moves to Netty 4.2.

Quarkus 3.39.5 (#881) was already on `main`.

### Verified

On the new dependencies: `GeminiThoughtSignatureTest`, `LanguageModelBuildersTest`, `ReactorNettyNettyCompatibilityTest`, the OpenAI-compatible builder and provider tests, and the tool-loop tests. `LanguageModelBuildersTest` errored inside the sandbox with "Unable to establish loopback connection" (an environment limit on starting a Java HTTP client), and passed 45/45 outside it.

***

## 🐛 fix(llm): a timed-out or failed cascade step hands its completed tools to the next step (2026-09-30)

**Repo:** EDDI (`fix/cascade-partial-tool-exchange`)

Closes the follow-up the 2026-09-26 tool-loop entry left open ("Not carried (follow-up)"): a cascade step carried its tool exchange into the next step only when it *returned*. A step whose future timed out or threw lost the exchange with its result, so the next step started from the conversation alone and could execute the same side-effecting tools again.

### What changed

* **`ToolExchangeRecorder`** (new, `modules/llm/impl`) — a per-run, thread-safe record of the tool loop's exchange, appended as it happens. It holds only complete pairs: a call is recorded in the same step as its result, and an assistant message that asked for several tools is reported with the calls that have a result and nothing else. A run cancelled between two tools of one batch therefore never leaves a dangling call. Its output goes through `ToolLoopRunner.toolExchange`, so null-id calls get the same synthetic, paired ids a returned exchange gets.
* **`ToolLoopRunner`** — `runToolCallLoop` and a new `executeWithTools` overload take the recorder; every place the loop appends a tool result (executed, self-conversation refusal, pause-cap denial) now goes through one `addResult` helper that appends and records together. The old `executeWithTools` signature passes `null` (records nothing); the resume path passes `null` too.
* **`IAgentOrchestrator` / `AgentOrchestrator`** — a new `executeIfToolsEnabled` overload with the recorder. The interface default ignores it and delegates to the existing method, so another implementation keeps the pre-change behaviour (nothing to carry) rather than failing.
* **`CascadingModelExecutor`** — each step attempt gets a recorder (a fresh one for the carried-exchange-rejected retry). In the `TimeoutException` and generic `Exception` paths, after the future is cancelled, `carryCompletedExchange` applies the success path's rule: the carried exchange becomes what the step started from plus what it completed. The step's trace entry records `completedToolMessages`. The `ToolApprovalRequiredException` branch is untouched; it rethrows before the recorder is read.

### Design decisions

* **A step that completed nothing leaves the carried exchange alone** rather than replacing it with an empty one. There is nothing new to hand on, and dropping an exchange the next provider might accept would only invite a replay.
* **A tool still executing when the step is cancelled is not carried.** Its outcome is unknown. `cancel(true)` does not wait for the virtual thread, and waiting for it would defeat the step timeout. This is the one replay the change cannot prevent, and `docs/model-cascade.md` says so.
* **A failed step's tool trace entries are still not kept.** The next step receives its completed calls, but the returned tool trace lists only the calls of steps that returned; `completedToolMessages` on the failed step's cascade trace entry is the record that they ran. `docs/model-cascade.md` now says so rather than claiming the trace covers every step (review of #916). Merging them is a follow-up.
* **A recorder passed in, not state on the runner.** `ToolLoopRunner` is shared by every conversation and stays stateless; the recorder lives exactly as long as one step attempt.

### Tests

* `CascadingModelExecutorPartialExchangeTest` (new) runs the real `ToolLoopRunner` under the cascade, with a tool that counts its executions. A step that completes `placeOrder` and then hangs (timeout) or throws on the next model request hands the call and its result to step 1, and the order is placed once. A step that fails before any tool (`FailedBeforeToolsException`) carries nothing. With `carryToolResultsOnEscalation: false` nothing is carried and the tool runs again, as documented.
* `ToolExchangeRecorderTest` (new) covers complete batches, a batch cut between two tools, round ordering and null-id pairing.
* Existing cascade and `LlmTask` cascade tests stub the new overload, because the cascade now calls it.

Docs: [`model-cascade.md`](/agent-configuration/model-cascade.md) replaces the known-limitation sentence with the new behaviour and the one remaining case.

***

## 🐛 fix(manager): model suggestions that vendors retired, renamed or never had (2026-09-30)

**Repo:** EDDI (`fix/manager-model-suggestions`)

### Why

While building the model catalog for eddi.technology, every id the Manager suggests was checked against the vendors' own documentation (2026-09-30). Several could not load: an agent created with one saves and deploys cleanly, then fails on its first message.

### What changed

* **`ui/manager/src/lib/model-suggestions.ts`**
  * Gemini: dropped the bare `gemini-3.1-pro` (Gemini API and Vertex lists). Gemini 3.1 Pro exists only as `gemini-3.1-pro-preview` / `-customtools`, which stay.
  * OpenAI: added `gpt-6.1-sol` (2026-09-29), which supersedes `gpt-6-sol`; the older id is still served and stays.
  * Ollama: `phi4:mini` → `phi4-mini` (the `phi4` library only has 14b tags).
  * Hugging Face: `THUDM/GLM-5.1` → `zai-org/GLM-5.1`.
  * Mistral: removed `devstral-latest`, `devstral-small-latest` (Devstral retired by 2026-07-31) and the `magistral-*-latest` aliases (their targets were retired 2026-07-31; reasoning is now `reasoning_effort` on Small and Medium). `mistral-small-4` → the documented `mistral-small-2603`. Added the documented dated ids `mistral-large-2512`, `mistral-medium-3-5`, `ministral-14b-2512` and `codestral-2508` beside the `-latest` aliases.
  * Bedrock: Llama 4 Maverick and Scout now suggest the cross-region profiles `us.meta.llama4-…-instruct-v1:0`. Bedrock has no in-region on-demand support for Llama 4, so the bare id fails.
  * Oracle GenAI: `cohere.command-latest` and `cohere.command-plus-latest` never existed on OCI, and Command R / R+ are retired there. Replaced with `cohere.command-a-03-2025`, `-reasoning` and `-vision`. The Llama 4 entry used the Hugging Face repo name; it is now OCI's `meta.llama-4-maverick-17b-128e-instruct-fp8`. The unverified Scout entry was dropped.
* **`ui/manager/src/lib/api/agent-setup.ts`**: the Oracle GenAI default model `cohere.command-r-plus-v2` → `cohere.command-a-03-2025`.
* **`docs/langchain.md`**: the Oracle GenAI example and provider line name Command A.
* **Platform Operator revision 1 → 2** (`operator-revision.json`): the Operator's system prompt carries a model catalogue built from these suggestions, so existing Operators are told to upgrade and stop recommending the retired ids.
* **`pom.xml`: Jackson overrides 2.22.2 → 2.22.3** (core, databind, dataformat-csv, dataformat-xml). CVE-2026-91776 and CVE-2026-91777 in jackson-databind 2.22.2 were published on 2026-09-30 and turned `Trivy Filesystem Scan` red on every PR. Dependabot's #913 carries the same bump inside a larger group whose build is failing, so the security fix is taken here on its own.
* **Test**: `model-suggestions.test.ts` lists the retired ids with the reason for each and fails if any provider suggests one or defaults to one.

### Not changed

* `claude-haiku-4-5` stays: it is active, though Anthropic only guarantees it until 2026-10-15, so check the deprecation page after that date.
* `deepseek-v4-flash` stays in the vision-token list: the alias still routes to V4.1-Flash.
* The legacy rule-based reference agent under `docs/agent-configs/rule-based-reference/` still offers older model ids as quick replies; it is a historical sample and was left as it is.
* The `openai/gpt-oss-*` ids under Oracle GenAI were not verified against OCI's naming and were left.

***

## 🐛 fix(operator): knowledge-base diagnosis, a working screen context, and an upgrade path for existing operators (2026-09-30)

**Repo:** EDDI (`fix/operator-rag-knowledge`)

### Why

A review of the Platform Operator against what shipped since its knowledge-base section (2026-09-17) found four gaps. The largest was not about RAG at all: **no prompt improvement ever reached an operator that already existed.** Activation stores the instructions on the config, and the form re-seeded every Reconfigure from that stored copy (`initial.promptBody || default`). So every operator activated before 09-17 still had no knowledge-base section, and none had the 5.5 model catalogue.

### What changed

**What the operator knows about knowledge bases** ([`system-prompt.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/operator/system-prompt.ts), [`tool-scopes.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/operator/tool-scopes.ts))

* **The three-part binding.** A KB reaches an agent only through the KB document, an `eddi://ai.labs.rag` workflow step, and the LLM task's `knowledgeBases` / `enableWorkflowRag`. Any missing piece fails silently. The prompt stated only the step. It now walks the three in order, as [`rag.md`](/agent-configuration/rag.md#troubleshooting)'s table does.
* **Names match on the document's own `name` field.** That is not the descriptor name the prompt told the operator to search by, and the two can differ. `httpCallRag` and `maxRagContextChars` are named as well.
* **`in-memory` is flagged.** It is the default `storeType`, and it empties itself on restart, after 30 idle minutes, and on any secret change.
* **"Did the documents land?" is now answerable.** New read grants `GET /ragstore/rags/{id}/sources/{sourceId}/runs` and `…/files`, each with its own predicate and prompt line. The existing ingestion-status read needs an id that only a direct `POST …/ingest` returns, so the operator could never use it; its line now says so. A KB with no `sources` is stated as not checkable. Run, preview, upload, file delete and purge stay excluded, and a test pins that.
* **Connecting an existing KB** (read-write only) is described when the operator holds the KB reads and both `PUT /workflowstore/workflows/{id}` and `PUT /llmstore/llms/{id}` (`grantsKnowledgeBaseBinding`). The prompt also says that a deploy can be refused by vault-grant enforcement, which the operator cannot fix, and that binding makes the KB's documents answerable to the agent's users.

**Screen context** ([`use-current-screen-context.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-current-screen-context.ts))

* The viewed group was sent as `groupId`, an engine-reserved key that `ClientContextGuard` strips since the 09-26 hardening, so "(group …)" never rendered. It is now `viewedGroupId`.
* `resourceType`/`resourceId`, `conversationId` and `channelId` were sent all along but never read by the template. They are now rendered. On a knowledge base's own page, the operator now knows which KB is meant.
* A test fails if the template ever reads a reserved key again.

**Operator upgrades** ([`operator-revision.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/operator/operator-revision.ts), [`operator-upgrade.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/operator/operator-upgrade.tsx), [`OperatorRevisionCheck.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/api/OperatorRevisionCheck.java))

* A provisioning revision lives in [`operator-revision.json`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/operator/operator-revision.json), starting at 1; configs from before this are read as 0.
* Activation stamps three fields into the config: `provisionedRevision`, `provisionedEndpoints` and `promptBodyIsDefault`. An upgrade also carries the model endpoint `llmBaseUrl`, which named OpenAI-compatible providers (#904) started storing.
* `operator-revision.test.ts` hashes everything provisioning derives (both scopes' endpoints, preambles and default bodies, plus the gate). It fails when that changes without a revision bump, and prints the new fingerprint.
* **Shown in the Manager:**
  * an *Upgrade* banner on the operator page, listing the tools added and removed;
  * a hint and a dot on the docked drawer;
  * a dashboard line;
  * one `console.warn` per page load.
* **Shown in the server log:** a startup `WARN` from `OperatorRevisionCheck`. It reads the same JSON, which `pom.xml` now copies onto the classpath on every build, `-DskipUi` included.
* **Upgrade is one click.** It is a Reconfigure with every setting carried over, so it runs through every activation check before the old operator is retired. Instructions the admin never edited get the new default. Edited ones are kept unless the admin picks the new default. Legacy configs whose text differs from today's default ask, with *keep* preselected: that text is usually an old default, but it may be an edit, and a click-through must not discard it.
* An operator with a plaintext key (never stored) or an unrecorded model-server address opens the prefilled form instead.
* The form now seeds today's default when the stored text was a default, and offers *Reset to default* whenever the text differs.

**Process** ([`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md) §4.6, [`ui/manager/AGENTS.md`](https://github.com/labsai/EDDI/tree/main/ui/manager/AGENTS.md))

* New rule: when functionality is added, changed or removed, check the operator's prompt and allow-list alongside the docs. Any change to them is a revision bump.

### Decisions

* **Upgrade replaces rather than versions the agent in place.** `setup-api` only creates. An in-place new version would need a backend update mode that regenerates the `apicalls` tools from the spec. Replacement reuses the one path that already proves a new operator safe. Cost: the operator chat ends, and old operator conversations leave its history. The confirmation dialog says both.
* **A fingerprint test, not a computed-at-runtime hash.** The backend needs a plain number it can compare. The test keeps that number honest without a build-time code generator.
* **No Sidebar badge.** `Sidebar` is part of the synced design system, and a data hook there would break its preview bundle. The drawer launcher, present on every page of both shells, carries the dot instead.
* **Paused operators are not flagged.** Activating one again always provisions the current revision.

### Next

* Existing deployments will see the upgrade notice on first start of the release carrying this. Nothing is migrated automatically.

***

## 🧪 test(manager): the operator fallback test expects the new default Anthropic model (2026-09-30)

**Repo:** EDDI (`fix/operator-test-default-model`)

### Why

`main` has failed `UI Manager Checks` since #903 merged. #896 made `claude-sonnet-5-5` the first Anthropic model suggestion, and the operator setup falls back to a provider's first suggestion when its stored provider is no longer offered. `operator-activation.test.tsx` still expected `claude-sonnet-5`. #896's own CI ran before that test existed on its base, so neither PR saw the combination.

### What changed

* `ui/manager/src/components/operator/__tests__/operator-activation.test.tsx`: the fallback case expects `claude-sonnet-5-5`. No production code changes.

***

## 🐛 fix(runtime, groups): an idle limit of -1 no longer ends every conversation; stored NEGOTIATION groups arbitrate again (2026-09-30)

**Repo:** EDDI (`fix/650-idle-negotiation`)

### The idle sweep's `-1`

`AgentDeploymentManagement.manageAgentDeployments` runs daily, first five minutes after boot. It ends conversations idle longer than `eddi.conversations.maximumLifeTimeOfIdleConversationsInDays` and undeploys old agent versions nothing active uses. The idle check is `DAYS.between(lastInteraction, today) >= limit`, so a limit of `-1` or `0` counted **every** conversation as idle and ended all of them. `-1` is how the two neighbouring retention settings say "never", so an operator copying that idiom, for instance to keep a 5.x database's conversations open through the upgrade, closed every conversation instead.

* A limit below 1 now **disables** idle-ending (the same threshold as the retention sweep). The sweep loads and ends no conversation for idleness, and startup logs once that it is off.
* **Undeploying is unchanged.** The sweep still deploys the latest version of each agent, still retires an old version whose conversations can move to a newer compatible one, and still undeploys an old version with **no** active conversation. None of that ends a conversation: an idle conversation is still open, so it counts as active and keeps its version deployed. Stopping those undeploys too would leave superseded versions holding memory for ever, which needs a setting of its own. The reasoning is in `idleEndingEnabled()`'s Javadoc.
* Documented in [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md), `application.properties` and [`gdpr-compliance.md`](/security-and-compliance/gdpr-compliance.md).

### NEGOTIATION groups stored with a prompt-less Arbitration phase

A NEGOTIATION group can be stored with its phases materialized and the Arbitration phase's `inputTemplate` null. The Manager used to save exactly that (enabling an approval point materializes the phases), and the REST and MCP APIs and ZIP import accept it as given. There was no error at run time: `GroupContextBuilder` fell back to the generic SYNTHESIS prompt ("synthesize a balanced conclusion"), so when bargaining failed the moderator summarised the deadlock instead of deciding it, and `GroupConversationService` still recorded that summary as the arbitrated VERDICT. Only the Manager repaired such a group, on its next save.

* `DiscussionStylePresets.withNegotiationArbitrationRepaired` puts `TEMPLATE_ARBITRATION` back on that one phase. The test is the Manager's `repairNegotiationArbitration`: NEGOTIATION style, named `Arbitration`, a MODERATOR SYNTHESIS skipped on `AGREEMENT_REACHED`, no template. Any other phase, or one with a template, is left as written.
* **Run time:** `GroupConversationService.resolvePhases` and `effectivePhases` (a persisted runtime phase list) apply it, so groups already stored this way arbitrate without being re-saved.
* **Save time:** `AgentGroupStore` create and update store the repaired phase, whichever client sent it. It is filled in rather than rejected with a 400, because the Manager itself saved this shape and such groups must stay saveable through the API that stored them.
* The Manager's own repair is kept; it and the backend now agree.

**Files:** [`AgentDeploymentManagement.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java), [`DiscussionStylePresets.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java), [`GroupConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java), [`AgentGroupStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java); tests `AgentDeploymentManagementIdleSweepTest`, `NegotiationArbitrationRepairTest`, `AgentGroupStoreNegotiationArbitrationTest`.

***

## 🐛 fix(migration): conversation descriptors and steps reach the v6 shape on databases an earlier 6.x migrated; an unconvertible template is an ERROR once (2026-09-30)

**Repo:** EDDI (`fix/650-migration-descriptors`)

Findings from the second 5.x → 6.5 first-boot rehearsal and its docs review.

### 1. Per-agent conversation listings were empty after a 5.x upgrade

EDDI 5 stored a conversation descriptor's agent as `botResource` and `botName`. The rename migration rewrote the URI inside `botResource` but never the field's name, and the 6.x `ConversationDescriptor` reads `agentResource`. So `GET /conversationstore/conversations?agentId=…` returned none of the conversations EDDI 5 created, with or without `agentVersion`, and the text filter on the agent's name (`agentName` is one of the indexed search fields) missed them too.

Worse, on a database an earlier 6.x migrated: 6.x read such a descriptor without its agent and every write of a descriptor is a whole replace, so each turn, and each conversation the idle sweep ended, stored the descriptor with **neither** name.

* **`V6RenameMigration`** — a new server-side pass renames `botResource` → `agentResource` and `botName` → `agentName` in `descriptors` and `descriptors.history` (one `updateMany` with a pipeline; a document holding both names of a pair is left alone, counted and reported, as for conversations). A second pass gives an `agentResource` back to the conversation descriptors that lost both names, from the `agentId`/`agentVersion` of the conversation with the same id, in batches; a descriptor whose conversation is gone is counted and reported, not failed. Both run as step 7 of a first migration, and as a **catch-up** on a database whose rename migration an earlier 6.x recorded complete (the #908 pattern for triggers), until it succeeds once: it records `v6-rename-descriptor-fields-complete` in `migrationlog`.
* **`ConversationDescriptor`** — reads `botResource` and `botName` through private write-only setters, so an unmigrated descriptor reads with its agent and is written back with the v6 names.
* **`RestConversationStore`** — the listing falls back to the conversation's own agent when a descriptor names none, so a descriptor stripped after the catch-up ran (by a replica still on an earlier 6.x) is still listed.

### 2. A database 6.4 migrated kept the v5 step shape of every EDDI 5 conversation

The step rename (`conversationSteps[].packages` → `workflows`, #907/#909) runs only as part of a first migration. On a database whose `v6-rename-migration-complete` an earlier 6.x recorded, it never ran, and those conversations loaded only through `@JsonAlias("packages")` on the snapshot — while a comment said nothing depended on that alias.

* **`V6RenameMigration`** — the same server-side pipeline now also runs as a catch-up on such a database (idempotent, counted, `_rev`/`_histRev` bumped as #909's passes do), recorded as `v6-rename-step-shape-complete`. Both catch-ups go through one `catchUp(key, …)` helper; a first migration records both keys with its completion.
* **`ConversationMemorySnapshot`** / `migrateConversationStepShape` Javadoc — the alias is a safety net that has to stay (a database awaiting its catch-up, a v5 step written after the pass), and now say so.

### 3. An unconvertible Thymeleaf template was logged at ERROR on every boot

A template `V6QuteMigration` refuses (one that builds template syntax) keeps the migration incomplete, so it re-ran and re-reported the same document on every boot, for as long as the legacy config existed.

* **`V6QuteMigration`** — the first boot that finds a refused document logs an ERROR with the collection, id (with the version, for a history row), field paths and the remedy. Later boots list the documents already reported in **one WARN**. The reported set is kept in `migrationlog` under `v6-qute-migration-unconvertible` (an `entries` list); a document fixed since is converted and dropped from it, a new refused one gets its own ERROR, and the record is removed when none is left. The end-of-run summary is an ERROR only for real failures (an unreadable collection, a failed write); refusals alone end with an INFO. A cursor failing part-way through a collection is now counted as a failure instead of escaping the pass.
* **`IMigrationLogStore`** — `readMigrationEntries` / `writeMigrationEntries`, implemented by the MongoDB store on a plain document. The defaults keep nothing, so a store without them reports as on a first boot every time (the PostgreSQL store; the Qute migration reads MongoDB anyway).
* **Docs** — [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md) (`eddi.migration.v6-qute.enabled`) and [`output-templating.md`](/agent-configuration/output-templating.md).

### Design decisions

* **The Qute migration stays incomplete while any document is refused, reported or not.** Completion means no stored template is still Thymeleaf, and it is also what stops the scan: once complete, a refused document fixed later would never be converted, and a refused one restored later would never be reported. Re-checking only the recorded ones would be a second path through the same four config collections; the full scan is cheap, and it is what an incomplete migration already cost.
* **The descriptor passes bump no revision**, unlike the conversation passes of #909. A descriptor has no `_rev`; its `_version` is part of the conversation's URI (`?version=`) and must not move, and descriptor writes are unconditional replaces that a bump would not guard anyway. A replace racing the pass is safe from 6.5 on, because the model reads the v5 names.
* **Not `@JsonAlias`.** With an alias, a document holding both names reads as whichever key comes last; the setters let the v6 name win in either order.
* **No alias for `ConversationMemorySnapshot.botId`/`botVersion`.** Conversations are migrated server-side before anything loads them, and a snapshot save `$set`s fields rather than replacing the document: an alias would leave `botId` beside the new `agentId`, which the migration then reports as ambiguous forever.

### Tests

* `V6RenameMigrationConversationDescriptorsTest` (Testcontainers): first boot, current and history, listed through the real `RestConversationStore` filter by agent and by agent + version; the catch-up on a database an earlier 6.x migrated; runs once; idempotent; ambiguous left alone; the backfill; reading the v5 names and writing back the v6 ones; v6 wins in either key order; the listing's fallback to the conversation.
* `V6RenameMigrationFirstBootTest` (Testcontainers): the step-shape catch-up on a database an earlier 6.x migrated — renamed, revision bumped, loads with the same item counts, a second run changes nothing — and it does not run once recorded.
* `V6QuteMigrationReportingTest` (Testcontainers): ERROR per document on the first boot; one WARN and no ERROR on the second; a fixed document converted and dropped, completion once none is left; a new refused document gets its own ERROR; a history row named with its version.
* `LogCaptureSupport.captureRecordsOf` — captures the level and the formatted message.
* Every behavioural test above was mutation-checked: 18 mutants (each fix reverted in turn), all killed by the test written for it.

### Open

* A refused template that is fixed through the API leaves its old version in the `.history` collection, where it is still refused: the migration completes only once that row is corrected or removed by hand. That is deliberate — an agent pinned to the old version still reads it — and the ERROR says so.

***

## 📝 docs(readme): bring the README up to what 6.5.0 ships (2026-09-30)

**Repo:** EDDI (`docs/650-readme`)

### Why

The README was last refreshed around 6.4.0. Since then the RAG ingestion pipeline (web crawler, file uploads, scheduled sources, the Manager panel), agent version following, LLM telemetry, MCP OAuth sign-in, editable vault grants and a round of fail-closed security changes landed, and several existing claims had drifted from the code.

### What changed

* **Added** (each checked against the code or the page it links): discussion overview, agent version following, the OpenAI Chat Completions row in the standards table, MCP OAuth sign-in and the MCP client, connections, caller identity, secret context values, editable vault grants, workspaces and sharing, the restricted template engine, memory guardrails, the `secret` property scope, RAG crawler / upload / scheduled sources, LLM telemetry meters and span, global variables, the Manager approvals page, the Workforce workspace and the `/manage` / `/workforce` / `/chat` entry points, the loopback-by-default compose bind, the `ollama-nvidia`, `mcp-sidecar` and `openwebui` compose files, `mise.toml`, an upgrade callout under *Updating*, and ten rows in the documentation table.
* **Corrected**: embedding providers 7 → 8 (Gemini was missing; `EmbeddingModelFactory`), memory visibility `global`/`agent`/`group` → `self`/`group`/`global` (`Property.Visibility`), prompt-snippet syntax `{{snippets.x}}` → `{snippets.x}` (Qute), GDPR erasure "across 6 stores" → every store (the cascade now has well over a dozen steps), MCP circuit breaker "60s cooldown" → "3 failures within 60 s" (`McpToolProviderManager`), `install.sh --full` selects PostgreSQL.
* **Removed**: the ZAP DAST line from the CI security gates. `ci.yml` removed that job; its comment says not to cite DAST until it is rebuilt.

### Decisions

* No concrete image tag is written, so the README stays out of the release-pointer sweep until the 6.5.0 image exists.
* The compose bind-address variable for EDDI is not named in the README: `ConfigurationReferenceCoverageTest` treats every documented `EDDI_*` name as a property unless its allow-list of compose variables lists it, and that list does not include this one yet. The upgrade guide words it the same way.
* Screenshots are unchanged: none of the new surfaces has one yet, and a caption must not describe a picture that does not show it.

***

## 🔒 fix(schedules): a configuration PUT no longer re-enables a schedule someone else disabled (2026-09-30)

**Repo:** EDDI (`fix/schedule-put-preserves-enabled`)

### What changed and why

`PUT /schedulestore/schedules/{id}` wrote every field, including `enabled`, from the request body. It had no version or expected-state check. The Manager's schedule editor sent `enabled` from the copy it had read. If another operator disabled the schedule after that read, the editor's Save switched it back on, and the schedule fired again. Leaving `enabled` out of the body would not have helped: the Java model defaults it to `true`, so an omitted value read as "enable". CodeRabbit raised this on labsai/EDDI#854 (review 5357550681) as a server-contract gap outside that UI PR.

The fix makes `enabled` a switch that only `/enable` and `/disable` flip. That is option 1 of the two considered.

* **Both stores' `updateSchedule`** leave `enabled` out of the write: the Mongo `$set` list and the Postgres `UPDATE … SET` list. The stored value is kept inside the same atomic write, so no read-then-write window exists. This is the same treatment `fireStatus` and `failCount` already get. The Postgres parameter indexes after `max_cost_per_fire` shift down by one.
* **`RestScheduleStore.updateSchedule`** copies the stored `enabled` onto the body in `carryOverNonEditableFields`. This keeps the in-memory body consistent with the row; the store is what guarantees the behaviour. A PUT with `"enabled": false` does not disable either. The OpenAPI description of the PUT says so.
* **Manager editor** (`ui/manager/src/pages/schedules.tsx`): an edit no longer sends `enabled`. A create still sends `true`. The row toggle already used `/enable` and `/disable`.
* **Docs:** [`scheduling.md`](/conversations-and-orchestration/scheduling.md#enabling-and-disabling) has a new "Enabling and disabling" section, plus notes on the field and PUT rows. [`import-export-an-agent.md`](/getting-started/import-export-an-agent.md#what-happens-to-schedules-on-import) notes the effect on imports.

### Behaviour changes to know

* Editing a one-shot that has already fired, and so disabled itself, no longer re-arms it. Save the new time, then call `/enable`.
* An import with `strategy=merge` that updates an existing schedule now keeps the target's `enabled` state instead of taking the archive's. This matches how a merge already keeps the schedule's owner. Rollback of a failed import needs no change, because the import never alters `enabled` on an existing schedule.

### Tests

* `RestScheduleStoreTest`: a stale `"enabled": true` body, or one that omits `enabled`, keeps a stored `enabled=false` and never calls `setScheduleEnabled`. A body with `"enabled": false` does not disable.
* `MongoScheduleStoreTest` (unit) and `PostgresScheduleStoreUnitTest`: the update never names `enabled`. The shifted Postgres parameter indexes are re-asserted.
* `datastore/mongo/MongoScheduleStoreTest` and `PostgresScheduleStoreTest` (Testcontainers; CI runs them) cover the race: read, then `setScheduleEnabled(false)`, then `updateSchedule(staleCopy)`. The edit lands and the schedule stays disabled. The Postgres IT that expected a PUT to disable now asserts the opposite.
* Manager `schedules-regressions.test.tsx`: an edit's PUT body has no `enabled`, even when the snapshot the dialog opened on is stale, and Save never calls `/enable`.

***

## 📝 docs(upgrade): an upgrade guide from 6.4 to 6.5 (2026-09-30)

**Repo:** EDDI (`docs/650-upgrade-guide`)

### Why

An operator upgrading an existing 6.4.x deployment had no single place that says what breaks and what to change. Several 6.5 changes take a working 6.4 deployment down without one: a Keycloak realm imported from the 6.1–6.4 realm file answers `401` on every request, an IdP that delivers roles outside `realm_access/roles` answers `403`, `helm upgrade` with the in-chart MongoDB refuses to render, and a weak vault master key stops a production boot.

### What changed

* New [`upgrading-from-6.4.md`](/deployment-and-infrastructure/upgrading-from-6.4.md), in the shape of [`upgrading-from-5x.md`](/deployment-and-infrastructure/upgrading-from-5x.md): before you start (backup, no mixed versions, no rollback without the backup), the changes that break a working deployment, then authentication, refusals, outbound HTTP, Helm/Kubernetes/Compose/image, client-visible REST, MCP and SSE changes, automatic database changes, the extra checks for a database that 6.4 migrated from 5.x, and how to check the result. Each item says what changed, who is affected and what to do, and links the detailed page instead of repeating it.
* Linked from the README documentation table and `docs/SUMMARY.md`, next to the 5.x guide.

### Decisions

* Every item was checked against the code on this branch (property, `@ConfigProperty` default, model field, refusing code path, role annotation, chart template), not taken from changelog entries. An earlier draft said HTTP calls resolve `${vault:…}` in URL, body and query "for the first time"; 6.4.0 already did, so the guide describes what is actually new: the rule that a credential reference must come from the configured template, and the fail-closed handling of an unresolvable one.
* The guide warns that two boot-time steps delete duplicate documents (`usermemories` identities and deployment rows), which is why the backup comes first.
* No concrete image tag is written: the release pointers still name 6.4.0 until the post-release update, so the guide tells Helm users to set `eddi.image.tag` explicitly.

***

## 📝 docs(upgrading): say that stored conversations and config history keep 5.x credentials (2026-09-30)

**Repo:** EDDI (`fix/6.5-release-readiness`)

### Why

[`upgrading-from-5x.md`](/deployment-and-infrastructure/upgrading-from-5x.md) §7, "What is not migrated automatically", listed plaintext credentials in agent configs, but not the copies EDDI 5 left elsewhere. EDDI 5 stored whatever a turn carried, and no migration rewrites stored conversations. A rehearsal against a production 5.5.1 database found credential-named fields in every one of its 195 conversations:

* properties copied from plaintext configs;
* a user token sent as context, in 174 of them;
* recorded `Authorization` request headers, in 93.

The config `.history` collections kept the plaintext as well. An operator following the guide would have believed the upgrade left no credentials outside the vault and the named backups.

### What changed

* §7 now names both leftovers, stored conversations and the config history, and says why rotating every credential a 5.x deployment used is the only complete fix.
* A `mongosh` script counts credential-named fields per collection and prints counts and field names, never values. It walks every nested object and array, so it doesn't depend on the stored shape, and it also catches property instructions that name the credential in a value (`{name: "…", valueString: …}`), which is how plaintext keys sit in property-setter configs. Verified against the 5.5.1 dump: it found every location found by hand during the staging migration (the conversations, API-call and LLM configs and their history, the property setters and their history).
* §8 points back to it.

***

## 🔖 chore(release): main builds 6.5.0 (2026-09-30)

**Repo:** EDDI (`fix/6.5-release-readiness`)

### Why

6.4.0 shipped on 2026-09-15, before the automatic post-release bump existed ([release-versioning.md](/deployment-and-infrastructure/release-versioning.md), "After a GA Release"). Nothing moved the build version afterwards, so `main` kept publishing `6.4.0-b<N>` snapshots for two weeks after 6.4.0 was released. A pre-release suffix sorts before the release, so each of them looked older than the version it came after.

### What changed

* `pom.xml` `6.4.0` → `6.5.0`, via `python scripts/bump-version.py next 6.5.0`. Everything that derives the build version (`application.properties`, the OpenAPI document, the image label, the Manager's sidebar) follows it.
* The two illustrative version examples that aren't release pointers now say 6.5.0: the Red Hat certification workflow's input description, and three help and comment lines in `install.sh`. The lines about realms imported from 6.1.0–6.4.0 are history and stay.

### Deliberately not changed

The release pointers (Helm `appVersion`, the k8s base and quickstart, the docs' copy-pasteable commands) stay on 6.4.0. They name the version a reader should deploy, and no 6.5.0 image exists yet. The post-release PR that `ci.yml` opens after the `6.5.0` tag's image is published moves them.

***

## 🔧 chore(ci): PR #846 reconciled with main — main's chat-ui ESLint kept, the CI hardening ported (2026-09-29)

**Repo:** EDDI (`chore/ci-hardening`, PR #846)

`main` moved while this branch was open: #856 / `4fdf4bedd` added ESLint to ui/chat and a `Lint` step to the `UI Chat` job, #870 slimmed AGENTS.md, #873 split the Slack notification into two sections, and #869 added release tooling (`release-pointers`, `post-release`). Merged `origin/main` and cut this branch to what `main` still lacks.

* **Kept main's:** `ui/chat/eslint.config.js`, `ui/chat/package.json`, `ui/chat/package-lock.json`, `ChatInput.tsx`, `ChatWidget.tsx` and `ui/chat/AGENTS.md` are exactly `main`'s. This branch's parallel ESLint setup (the `react-refresh` plugin, a `toBeGreaterThan(-1)` rule) and its lint fixes are dropped; `main`'s lint already passes on `main`'s sources. The duplicate `Lint` step in `UI Chat` is removed, leaving `main`'s (after `Type check`). `ui/chat` no longer differs from `main` at all.
* **Ported unchanged**, because `main` has none of it: `docker` needing `e2e-auth` + `e2e-gate`; `NODE_VERSION` for every `setup-node` and the verified Node archive in Build Image; the changelog guard's `--check` step, fence-aware heading count and collation-branch check; `.gitignore` anchoring and its `backend` filter entry; the `vitest-security` Dependabot groups; the Manager NUL escapes and `source-control-chars.test.ts`; the Jlama dev-mode flag in the docs; the realm checks in `DeploymentManifestsTest`; best-effort Slack delivery.
* **Merged by hand:** `BuildQualityGatesTest` keeps both this branch's tests and `main`'s `slackSectionsStayWithinTheFieldCap`. That test's Javadoc and the ci.yml comment beside the payload now say `curl -sf` was the old behaviour. `slackNotificationIsBestEffort` now skips shell comments, since that comment names `curl -sf` and the test was grading it as a command. In AGENTS.md, `main`'s text stays and only the Jlama flag and the changelog-guard description are applied on top.

**Files:** [`ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml), [`BuildQualityGatesTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/BuildQualityGatesTest.java), [`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md)

***

## 🔀 fix(deploy): deployment defaults reconciled with main's infra hardening (2026-09-29)

**Repo:** EDDI (`fix/deployment-defaults`) — merge of `origin/main` after PR #865 (`fix/security-infra`) and the frontend CSP batch landed overlapping fixes.

### Reconciled with main (main's version kept, this branch's duplicate dropped)

* **Helm MongoDB authentication.** Main requires `mongodb.rootPassword`, guards pre-auth upgrades with `mongodb.authMigrated` and URL-encodes the credential in the projected Secret. This branch's `mongodb.auth.*`, `eddi.enabledUnlessFalse`, the mounted `mongodb-secrets.properties`, the chart 3.0.0 bump and the matching `manifest-lint` cases are gone; the chart stays at main's 2.2.0.
* **Helm datastore NetworkPolicies.** Main's `templates/networkpolicy.yaml` isolates MongoDB, PostgreSQL, NATS and Keycloak behind `networkPolicy.enabled`; `datastore-networkpolicy.yaml` and `networkPolicy.datastores` are dropped.
* **Kustomize MongoDB NetworkPolicy:** main's `mongodb-networkpolicy.yaml` kept; the PostgreSQL policy this branch adds is renamed `postgres-networkpolicy.yaml` to match.
* **Loopback binds** in every compose overlay, `EDDI_BIND` and Keycloak's `KEYCLOAK_BIND` (this branch's `KEYCLOAK_BIND_ADDRESS` renamed to it).
* **`/chat` framing split** (`csp-chat`, per-filter `X-Frame-Options`, `form-action`/`base-uri`/`object-src`) and main's `CspPolicyTest` cases.
* **Compose admin credentials.** Main's semantics: `docker-compose.auth.yml` reads `KC_BOOTSTRAP_ADMIN_USERNAME`/`_PASSWORD` and `docker-compose.monitoring.yml` `GRAFANA_ADMIN_USER`/`_PASSWORD`, each defaulting to `admin` — a loopback-only dev default, so a plain `docker compose up` starts without any setting. This branch's required `KEYCLOAK_ADMIN_PASSWORD` / `GRAFANA_ADMIN_PASSWORD` (`:?`), the `.env.example` block, the Manager's `docker-compose.keycloak.yml` requirement and the Linux `eddi update` pre-flight for them are gone. `ComposeStackTest` now pins the overridable-with-default form instead.
* **GCP provisioner:** main's scoped firewall, VM-side Keycloak admin password (`/root/.eddi-keycloak-admin`, exported as `KC_BOOTSTRAP_ADMIN_*`) and SSH-tunnel banner; `install.sh` keeps an exported value rather than generating one, and records it in `.env`.
* `ci.yml`, `README.md` and the Helm install snippets in [`getting-started.md`](/getting-started/getting-started.md) and [`kubernetes.md`](/deployment-and-infrastructure/kubernetes.md) take main's text (`mongodb.rootPassword`, the OIDC-off opt-ins).

### What this branch still adds on top of main

No realm account ships a password and `eddi-frontend` refuses the password grant (all three realms, the E2E realm generator, the installers' repair of existing realms); both installers generate the Keycloak and Grafana admin passwords and record them in `.env` under main's names (`KC_BOOTSTRAP_ADMIN_*`, `GRAFANA_ADMIN_USER`/`_PASSWORD`), move a running service still on `admin`/`admin` to them, and warn (rather than stop) when a running service rejects a stored or operator-supplied password, while a Grafana password generated by the same run that Grafana rejects stops the install before the success banner; Kustomize MongoDB authentication and the PostgreSQL NetworkPolicy; `postgres-secret.yaml.example`; Grafana's `grafana-admin` Secret and the namespaced Prometheus Role; no service-account token in any pod (Helm `serviceAccount.automountToken`); CSP `img-src blob:` and `eddi.csp.extra-connect-sources`; the explicit `/q/metrics` policy; Helm values for `eddi.metrics.httpPolicy`, `eddi.chat.frameAncestors` and `eddi.oidc.resourceMetadata.resource`; the `AuthStartupGuard` message fix; and `Dockerfile.demo` copying `ui/`.

### Known gap

The Helm datastore NetworkPolicies stay main's opt-in (`networkPolicy.enabled`, default `false`). On a default Helm install MongoDB and PostgreSQL are protected by their passwords only, and the in-chart NATS is reachable from every pod in the cluster with no authentication.

***

## 🔒 chore(engine): PR #831 reconciled with #859/#860 — main's implementation kept where both fixed the same finding (2026-09-29)

**Repo:** EDDI (`fix/reserved-context-keys`)

`main` merged an independent fix for the same review (#859: `ClientContextGuard`; #860: converse ownership checks; #871: `ConversationGroups`). Merged `origin/main`, every conflict resolved to `main`'s side, then cut the branch to its delta:

* **Kept from `main`:** `ClientContextGuard` and its operator knob `eddi.conversation.client-context.permitted-reserved-keys` as the only reserved-key boundary (REST start/say, streaming, MCP trigger start); `converse_with_agent`'s ownership check and its refusal of unbound or ownerless continuations (C6); the fail-closed group-policy carry-over; the removed `groupId` property fallback and the shared `ConversationGroups` resolver (C3c's first half).
* **Dropped from this branch:** `ReservedContextKeys` and the strip in `ConversationService`'s `conversationId` overloads (Slack and `/v1` send no client context, so `main`'s boundary loses nothing), its duplicate boundary tests, and the started-conversation set (`dynamic:delegated_conversation_ids`) that let `converse_with_agent` continue only conversations it had started.
* **Ported onto `main`'s classes:** the exact-key reads and the prefix drop (into `ClientContextGuard`, respecting the permitted-keys knob), the earlier-step `groupId` verification (into `ConversationGroups`, now covering the write boundary too), and the delegation USE check — extended to continuations, which the started set used to cover, since `main`'s ownership rule admits any conversation of the same user.
* **Unchanged from this branch:** C3b creator marker, M-A1 recruitment check and admin flag, M-T2 retained-set seeding.
* `main`'s `CreateSubAgentTool` prompt-escaping tests (#858) now stub the origin-stamping `setupAgent` overload.

***

## 🔒 fix(secrets): secret-scope branch reconciled with main (2026-09-29)

**Repo:** EDDI (`fix/secret-scope-vault`)

Main fixed the shared auto-vault slot (C2) while this branch was open, with per-write slots in `AutoVaultedSecrets`, and landed the secret-input scrub of #856. Where both sides fixed the same thing, main's implementation is kept and this branch's copy is dropped. What this branch still adds was ported onto main's code.

### Dropped (main covers it)

* The per-conversation key `<agentId>.<conversationId>.<property>` (`AutoVaultReference`), the resolver-cache invalidation and the ignored `tenantId` property — main's slot is per write, so nothing is overwritten, and main keeps reading the tenant from the property.
* The conversation id allocated before the first turn: `newConversationId`, the `unpersisted` flag, `IAgent.startConversation(conversationId, …)`. Main's slot name does not need it.
* `deleteConversationSecrets`, the orphan sweep and `conversationExists` — main deletes slots with the conversation, in the retention sweep and on GDPR erasure.
* The refusal of legacy `<agentId>.<property>` references — main still accepts them in older conversations.
* The move of `ConfigReferenceGuard` to `ai.labs.eddi.secrets`. It stays in `modules.apicalls.impl`, now public, with `requireConfiguredParameters` added.
* Map-key replacement for short secrets. Main never renames a key for a short secret, so the new `SecretValueScrubber` exact mode replaces values only.

### Kept, on main's code

* [`SecretPropertyVault`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/properties/impl/SecretPropertyVault.java) holds main's `autoVaultSecret` and step scrub, unchanged. `PropertySetterTask` and `PrePostUtils` both call it:
  * a `fromObjectPath` value, which is not rendered again, as main decided;
  * a refusal of typed values;
  * the httpcall, MCP and LLM pre-request and post-response instructions;
  * a check of the name at run time;
  * `SecretScopeValidation` when a configuration is saved.
* The parameter guard for LLM tasks, cascade steps and judges. It runs on the HITL-resume path too, through `resolveStepModel`.
* The `ApiCallExecutor` echo and exception redaction, and `RequestRedactor.safeResponseLog`.
* Exact-match scrubbing of short secret context values. It is a new `EXACT` mode of `SecretValueScrubber` beside main's whole-token mode, and it reaches `Conversation` and `TurnAuditBuffer`.
* The `AgentSetupService` fail-closed vaulting and the vaulting of `apiAuth`.

### CodeRabbit review of `d44edc478`

* **Scrubbed longTerm properties are written back.** `Conversation` kept the *same* `Property` objects in its longTerm baseline. The turn-end secret scrub changes a property in place, so a loaded longTerm property that it scrubbed still equalled its baseline and was never written: the user-memory store kept the secret. This was main's baseline code; the ≥ 8-character scrub hit it already, and the exact match for short values made it reachable for PINs too. The baseline now holds independent copies.
* **`apiAuth` references are recognised, not guessed.** `AgentSetupService.vaultApiAuth` used to treat any value containing `${` as a reference and store it as given. Now:
  * A value that is exactly a supported reference (`vault`, `eddivault`, `connection`, `vars` or `caller`), optionally after a scheme, is kept.
  * A value that mixes a reference with other text is refused.
  * Anything else is vaulted, so a literal such as `Bearer test-token${` no longer reaches the httpcalls in plaintext.
* **Post-response output cannot render a token its own instructions vaulted.** `PrePostUtils.runPostResponse` scrubs the vaulted plaintexts from the template data before it builds the output and quick replies. `{tokenResponse.access_token}` in an output template no longer reaches the conversation output.
* **An oversize JSON body is redacted before it is cut.** `ApiCallExecutor` parses and scrubs the whole application/json body first, then serializes and truncates it. Cutting first made the body invalid JSON, and the text fallback that followed misses secrets under 8 characters. A body that is genuinely invalid JSON still falls back to text redaction.

### CodeRabbit review of `cd0515dc1`

* **Non-string secret values from a response are refused.** In an httpcall, MCP or LLM instruction with `scope: "secret"`, a `fromObjectPath` value that is a native object, array, number or boolean now fails the turn. It used to be replaced by an empty string, which the secret branch then skipped, so the secret was dropped silently.
* **Vault references survive the template-data scrub.** The scrub after a post-response instruction now leaves `${vault:…}` / `${eddivault:…}` references intact and never renames map keys: `SecretValueScrubber.scrubDeepKeepingReferences`. Otherwise a plaintext that occurs inside the property name broke two things: the property's own reference, because the slot name ends in the name, and its key in `properties`. The references are found by a linear scan, not a regex, so text full of unterminated `${vault:` openings cannot make the scrub quadratic (CodeQL `java/polynomial-redos` on the first version).
* **Setup errors no longer carry the vault provider's message.** The detail is logged on the server; the caller gets the guidance only.

***

## 🔒 fix(slack): reconcile the Slack HITL binding branch with main's channel-identity fix (2026-09-29)

**Repo:** EDDI (`fix/slack-hitl-binding`)

`main` landed `fix/security-channel-identity` (#861) while this branch was open, fixing the same Slack findings a different way: persisted approval-card records with a per-card id (`ISlackApprovalRecordStore`), event signatures bound to the channel's owner, DMs routed to the signing integration, mandatory `slack:<team>:<user>` ids with the workspace taken only from a declared `platformConfig.teamId`, and unique integration names. Wherever both sides fixed the same thing, **main's implementation is kept** and this branch's is dropped:

* Dropped: the `channelIntegrationId` start context and group origin mapping (main's approval records bind a decision to the card its integration posted); the `<integration>|<subject>|<pauseId>` button value and "out of date" / no-buttons card variants (main's card id is stronger); `SlackEventEnvelope` and `SlackSignatureVerifier.matchingSecret`; the optional `platformConfig.appId` pin; the routed-only name uniqueness check; the opt-in `eddi.slack.namespace-user-ids` / `eddi.slack.legacy-team-id` settings with the bare-id memory copy, and `SlackIdentityStartupCheck`; team-scoped (`T…:U…`) approver entries (a declared `teamId` covers the case, below).

What this branch still adds on top of main's Slack code:

* **A Slack decision carries the pause its card was checked against.** `SlackInteractivityHandler` matched the clicked card to the subject's current pause and then resumed in a second step, so a resume and re-pause in between went undetected (main documented this as a residual). The decision now carries `HitlDecision.pauseId`, which the engine re-checks under the resume CAS and in the group resume; a mismatch marks the card "already resolved".
* **Routes found by a sender-chosen timestamp are bound to the signing app.** The webhook binds a signature to the channel's owner, but a DM thread lock and a group-discussion follow-up are looked up by a timestamp in the body. `EventOrigin` now carries the verified signing secret, and every route the handler takes must belong to an integration or legacy connector holding it (`SlackEventHandler.routeMatchesSigner`). A DM thread reply takes its credentials only from an app that holds the secret *and* serves the locked target (`ChannelTargetRouter.threadCredentialsForDm`) — previously another app's secret could continue it, and the reply had no bot token at all. A follow-up must be in the discussion's own channel and come from the app that started it, and is answered with that route's token. A DM signed by a legacy connector goes to the legacy connector holding its secret (`resolveLegacyDefaultForDm`) instead of the first new-style integration.
* **An approver click from another workspace is refused when the integration declares its `teamId`.** A bare Slack user id is unique within one workspace only, so in a shared (Slack Connect) approval channel a user of another workspace could carry a listed approver's id; `SlackInteractivityHandler` now compares the clicker's `user.team_id` with the declared workspace.
* **A channel integration name may not contain `|`**, which separates the name from the subject and card id in a Slack button value; such a name made every decision on its cards unresolvable. An integration stored earlier with such a name still loads and routes, but its approval cards (conversation and group) are posted without buttons, with a WARN naming the rule (`SlackHitlSupport.isBindableIntegrationName`) — stored configs are not renamed automatically (CodeRabbit review).
* **Group approve answers a stale `pauseId` as a pause change.** `RestGroupConversation.approveGroupPhase` and `approveGroupPhaseStreaming` caught `GroupPauseMismatchException` only as its parent `GroupDiscussionException` and answered "not awaiting approval"; they now answer `409` (streaming: a `group_error`) telling the reviewer to re-read approval-status and decide again, like the conversation endpoint (CodeRabbit review).
* **`PAUSE_CHANGED` is in the documented MCP `errorCode` lists** of `hitl.md` and `mcp-server.md` (the latter also gains `CONFLICT` and `INTERNAL`, which the tools already return) (CodeRabbit review).

**Files:** [`SlackInteractivityHandler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackInteractivityHandler.java), [`SlackEventHandler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java), [`RestSlackWebhook.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java), [`ChannelTargetRouter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java), [`RestChannelIntegrationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java), [`SlackHitlSupport.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackHitlSupport.java), [`SlackGroupDiscussionListener.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java), [`RestGroupConversation.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java); docs [`slack-integration.md`](/protocols-and-integration/slack-integration.md), [`hitl.md`](/conversations-and-orchestration/hitl.md), [`mcp-server.md`](/protocols-and-integration/mcp-server.md).

***

## 🔐 fix(vault): reconcile key safety with main's secrets-crypto hardening (2026-09-29)

**Repo:** EDDI (`fix/vault-key-safety`, merge of `origin/main`)

`main` landed a parallel pass over the same code (the 2026-09-26 "security(secrets): vault master-key, checksum, crypto and reference hardening" entry). Where both sides fixed the same thing, main's implementation is kept and this branch's copy is dropped; what main still lacked is ported onto main's code.

**Kept from main (this branch's version dropped):**

* **Secret AAD (L-S1).** Main's `tenantId|keyName|dekId` associated data with the no-AAD fallback and no prefix. The `a1:` marker, the `String` AAD overloads of `EnvelopeCrypto` and the tenant/key binding are gone — one on-disk format. The DEK-wrapping AAD is dropped too: main does not bind DEK wrappings, and a second wrapping encoding is not worth a low-value binding (a swapped wrapping already fails at the secret's own AAD).
* **Insert-if-absent metadata.** `ISecretPersistence.setMetaValueIfAbsent` (null = no metadata store) replaces `putMetaValueIfAbsent`; the salt, the KEK check value and system values use it. Main's Mongo and Postgres implementations and tests are kept.
* **KEK rotation rollback.** Main's commit-failure rollback, weak-new-key refusal and checksum-key re-wrap stay. On top of them: the pending salt is persisted first, verify accepts DEKs already under the new KEK, the KEK check value is announced before the first re-wrap, re-wraps are guarded (`updateDekWrapping`, never an upsert that could resurrect a reset tenant's DEK) with a catch-up sweep, and the checksum key is re-read and re-wrapped last. A failed commit rolls the DEKs back and restores the check value; only if that rollback fails too does the announcement stay, and a re-run with the same keys completes it.

**Ported onto main's code:** salt race and fail-closed salt read (H6a), first-DEK insert (H6b), KEK check value / stale-replica refusal and take-back (H6c, m2), audit keyring and v5 (H6d, M1), keyed pseudonyms (L-S2), tenant-reset discard and system-tenant guard (L-S3, m4), value rotation keeps the grant (S1), grant precondition (S6), impact-analysis `UNKNOWN` (S7), `adopt-master-key` (B1).

**Adapted to main's additions:**

* **Checksum key.** Main's KEK-wrapped checksum key is covered by the stale-replica guard (not created under a retired KEK), opens with the pending-salt KEK after an interrupted legacy migration, and is discarded by `adopt-master-key` — reported as `checksumKeyReset` — and by the empty-vault adoption at startup when the adopted key cannot unwrap it. Otherwise every `store()` failed for good after a lost key.
* **Late audit entries.** Main's `markUserErased` rewrite now writes the keyed v5 pseudonym whenever the ledger signs, so an entry flushed after an erasure does not carry the unkeyed hash L-S2 removes.
* Main's tests are kept; four needed adapting: the checksum-key write failure targets only that key (the announcement precedes it), the racing-creator hide applies to the checksum-key read, the wrong-old-key refusal message starts with `KEK rotation failed` and keeps the unwrap failure as its cause, and the erased-user signature check verifies through the ledger (entries are v5 now).

**Files:** [`VaultSecretProvider.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java), [`VaultSaltManager.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/crypto/VaultSaltManager.java), [`ISecretPersistence.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/persistence/ISecretPersistence.java), [`AuditLedgerService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/audit/AuditLedgerService.java), [`secrets-vault.md`](/security-and-compliance/secrets-vault.md), [`gdpr-compliance.md`](/security-and-compliance/gdpr-compliance.md).

***

## 🔀 Reconciled with main (2026-09-29)

**Repo:** EDDI (`fix/workspace-authz-scoping`), merge of `origin/main` at `9cf30b8d8`.

Main landed parallel fixes for part of this batch while the PR was open (`8d24ae335` schedule and trigger ownership, `566631b94` parser role gate, `37a9b8336` owner-scoped schedule paging, `2eded8789` review follow-ups, `74233c293` dream schedules). Where both sides fixed the same thing, main's implementation was kept and this branch's copy dropped:

* **H2f parser**: main's `@RolesAllowed` + VIEW check, with main's tests. This branch adds only the sentence in `semantic-parser.md`.
* **H2a owner scoping**: main's `ScheduleOwnerScope`, `canAccessScheduleOwner` rule and `requireMutableSchedule` guard. `IScheduleStore.ListingScope` is gone; a refused direct read answers main's `403`, not the `404` this branch used. `requireOwnUserId` keeps main's exact `system:scheduler` exemption (the "any `system:` identity" widening is dropped).
* **H2b triggers**: main gates update/delete on **USE** of the stored targets (`requireUseOnStoredReferencedAgents`); this branch's EDIT gate and its decision-log row are dropped.

What main still lacked is ported onto main's types (`ScheduleOwnerScope`, `RestScheduleStore`):

* `ScheduleOwnerScope` gained two refinements, pushed into both queries: `sharedOnlyIfCreatedByCaller()` (workspaces enforced: shared rows only when `createdBy` is the caller, unless listing by an agent the caller may EDIT) and `withTeamCadences()` (workspaces off: every cadence, whoever created it). The two-argument constructor and `visibleTo` keep main's meaning.
* `RestScheduleStore`: `mayRead`/`mayAccess` replace `canAccessScheduleOwner` (HITL reads hidden from non-admins, team cadences reachable through their group, shared schedules owned by creator or by the team of what they drive under enforcement); fire logs and `/admin/failed` scoped to it; forged cadence bodies refused on create and on update unless the update echoes the same cadence's own markers (main's `guardManagedSchedule` lets group editors change a cadence's cron by PUT); `fireNow` needs EDIT on a cadence's group and rethrows `ForbiddenException` instead of a `500`.
* Kept from this branch unchanged (main has no equivalent): H1, H2c, H2d, H2e's `scheduleRef` check, H2g, H2h, H3, A1, A3, E5, the trigger listing/read filter with `TriggerNotVisibleException`, and the export's schedule filter (now on `ScheduleOwnerScope.isShared`).
* AGENTS.md takes main's text; the E5 self-URL sentence moved with its section to [`agent-config-authoring.md`](/agent-configuration/agent-config-authoring.md).

***

## 🐛 fix(conversations): review findings for version following (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following`) — found by reviewing Phases 0–3 against the code paths around them.

* **The daily deployment sweep ended conversations that could have moved.** It retires old versions by ending their idle conversations, then undeploying once none are left. For an old version with a newer compatible version ready, it now undeploys at once and ends nothing (`AgentDeploymentManagement.retireIfConversationsCanMove`): those conversations continue on the newer version whenever they return.
* **A group member's private conversation that ended mid-discussion failed the member for every remaining turn** (`MemberTurnExecutor`). It now continues in a fresh conversation, once; a second end in a row is an ordinary member failure.
* **The move was recorded before the turn was admitted.** The descriptor update and the switch counter ran when the version was resolved, before the quota check, so a refused turn left the descriptor naming a version the conversation never ran on. Both now run when the turn completes.
* **A queued turn rebuilt over a reloaded memory ran on the stored version's memory.** Main's H13a rebuilds a superseded queued turn over the current document, which holds the version it was stored on. The rebuilt memory now adopts the version resolved for the turn within its own generation (`ConversationService.adoptResolvedAgentVersion`), and the move is recorded against the version that document was stored on.
* **A failed descriptor update was never retried.** The next turn is already on the new version, so it saw no move and the listings named the old version for good. The memory now keeps `staleDescriptorAgentVersion` (persisted, absent otherwise) and every turn retries until the descriptor is current; the switch counter still counts the move once.
* **Undo and redo persisted properties by the wrong version's memory policy.** They read `userMemoryConfig` from the conversation's current version, and fell back to any version of its generation when that was gone. Compatible versions may default visibility differently, so they now read the version recorded on the step (`agent:version`); when it is not deployed the config stays unset and the sync falls back to never widening a scope, as it always did.
* **A failed merge import's rollback stays breaking**, deliberately: reusing the original generation would leave the latest version below the aborted import's, and the next breaking save would share a generation with that aborted version. Commented in `RestImportService`.
* **Docs**: [`docs/deployment-management-of-agents.md`](/conversations-and-orchestration/deployment-management-of-agents.md#running-conversations-and-new-agent-versions) has the full model; `hitl.md`, `architecture.md`, `scheduling.md`, `slack-integration.md`, `mcp-server.md` and `metrics.md` link to it, and the full-metrics dashboard charts `eddi_conversation_agent_version_switch_count`.

***

## ✨ feat(deployment): undeploy and deploy understand compatible versions (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following`) — Phase 3 of [`planning/agent-version-following-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/agent-version-following-plan.md)

### What changed

* **Undeploy** (`RestAgentAdministration.undeployAgent`): open conversations on a version no longer block it with 409, and are not ended by `endAllActiveConversations`, when another deployed version in the same environment has the same compatibility generation — they move there on their next turn. "Deployed" is the deployment records plus this node's registry (which also holds `autoDeploy=false` deployments). A version undeployed by the same call never counts: `undeployThisAndAllPreviousAgentVersions` runs its undeploys asynchronously, and counting them would strand the conversations. Everything else — no generation, a breaking successor, a record that cannot be read — behaves exactly as before.
* **Conversations ended by an undeploy record `agent-version-retired`**, via a new optional `endReason` on `POST /conversationstore/conversations/end`. Only known reasons are accepted (400 otherwise): the reason is shown to users, so it must never be caller-supplied text.
* **Schedules survive retiring an old version.** Undeploy disabled every schedule of the agent on any undeploy, so "deploy v6, undeploy v5" — the normal rollout — silently switched off every heartbeat. They are now disabled only when the call leaves no version of the agent deployed in that environment.
* **`GET /administration/{env}/deploymentimpact/{agentId}?version=N`**: for every other deployed version, its open conversations and whether they `FOLLOW` version N (same generation, older) or `STAY` (another generation, none, or newer). Requires EDIT on the agent, like undeploy.

***

## ✨ feat(conversations): conversations follow compatible versions of their agent (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following`) — Phase 2 of [`planning/agent-version-following-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/agent-version-following-plan.md)

### What changed

* **Per-turn resolution** (`ConversationService.resolveConversationAgent`, used by `say` and `sayStreaming`): a conversation with a compatibility generation runs on the highest `READY` version of that generation on this node, moving to it if it is elsewhere — forward when a compatible version is deployed, back when the newer one is undeployed (a rollback) or has not reached this node yet. A conversation without a generation, a paused one, and one whose generation has nothing ready here take the old path unchanged (`getAgent`, including its on-demand deploy).
* **Memory**: `compatibilityGeneration` on the conversation (set from the version it starts on, persisted on the snapshot); `agentVersion` is no longer final. `IAgentFactory` gains `getLatestReadyAgentOfGeneration`.
* **Every step records its version** as step data `agent:version`, and the first step after a move records `agent:switch = {from, to}`. The marker is not `agent:version…` because step lookups match keys by prefix — the first name tried shadowed the version key in the tests. Both are written at the **end** of the step: detailed snapshots list a step's data in insertion order and clients read it by position, so writing them first shifted every entry (`AgentEngineIT` caught it).
* A move updates the conversation descriptor's agent URI (best-effort; listings filter on it), logs at INFO and increments `eddi_conversation_agent_version_switch_count`. Undo/redo read the memory config from any version of the generation when the conversation's own is gone.

***

## ✨ feat(agents): each agent version records whether it is compatible with the previous one (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following`) — Phase 1 of [`planning/agent-version-following-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/agent-version-following-plan.md). No behaviour changes yet; Phase 2 makes conversations act on it.

### What changed

* **`AgentConfiguration.compatibilityGeneration`**, assigned by `AgentStore` on every write and never taken from the body: `create` starts at 1; `update(id, version, config, compatible)` keeps the previous generation when `compatible` is true and advances it otherwise; the plain `update` is breaking. A previous version without a generation (stored before this existed) starts a new chain either way, so its conversations stay pinned. Only the current version can be updated, so the previous version always holds the highest generation and no other version is read.
* **REST**: `PUT /agentstore/agents/{id}?version=N&compatible=true` and the same flag on `PUT /agentstore/agents/{id}/updateResourceUri`. Absent means `false`.
* **MCP**: `apply_agent_changes` takes `compatible` (default `false`) and reports `compatibleWithPreviousVersion` when it wrote a new agent version. It is the only MCP tool that creates an agent version from an existing one.
* **Import and sync** (`RestImportService`, `UpgradeExecutor`) always write a breaking version: the configuration comes from elsewhere and nobody has judged it against the conversations running here. `StructuralMatcher` leaves `compatibilityGeneration` out of the agent comparison — each instance numbers its own, and comparing them made an unchanged agent UPDATE on every sync.
* **`IAgent.getCompatibilityGeneration()`**, set when a version is deployed.

***

## 🐛 fix(conversations): an ended conversation no longer strands a Slack thread or a heartbeat (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following`) — Phase 0 of [`planning/agent-version-following-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/agent-version-following-plan.md)

### Why

The idle sweep and `undeploy?endAllActiveConversations=true` both end conversations, and two callers never recovered from that: a Slack thread kept sending to its ended conversation and was refused on every further message, and a `conversationStrategy=persistent` schedule kept firing into its ended conversation until the FAILED fires dead-lettered it.

### What changed

* **`ConversationService.say` / `sayStreaming`** refuse an ENDED conversation before looking its agent up (`rejectIfEnded`). The ended check used to come after the lookup, so an ended conversation whose version had been undeployed — the normal state after `endAll` — answered "agent not ready", which no client recovers from.
* **Slack** (`SlackEventHandler.openThreadConversation` / `sendInThread`): a mapped conversation that has ended or vanished is replaced by a fresh one, and a conversation that ends between opening and sending (including a queued turn dropped as ENDED) is replaced once and the message resent. When the old conversation ended because its agent version was retired, the thread is told first. A state that cannot be read keeps the conversation — a store hiccup never costs a thread.
* **`IUserConversationStore.deleteUserConversationIfMatches`** (Mongo + Postgres): the mapping is removed only while it still names the ended conversation, so two messages racing on one ended thread converge on one new conversation. Both stores now also report a raced duplicate insert as `ResourceAlreadyExistsException` (Mongo `E11000`, Postgres `23505`) instead of a raw driver error, which is what the existing create-race recovery listens for.
* **`ScheduleFireExecutor.resolveOrCreatePersistent`** treats an ENDED conversation like an unreadable one and creates a fresh conversation.
* **End reason**: `IConversationService.endConversation(id, endedBy, endReason)` records why a conversation ended (`endReason` on the stored and client-facing snapshots, a narrow field update on both stores). `END_REASON_AGENT_VERSION_RETIRED` is the first value; the undeploy path sets it in Phase 3.

***

## 📝 docs(planning): conversations follow compatible agent versions (2026-09-29)

**Repo:** EDDI (`docs/agent-version-following`)

### Why

A conversation is pinned to the agent version it started on for its whole life, so fixes never reach long-running conversations and old versions cannot be undeployed without ending conversations that could have carried on.

### What changed

* New plan: [`planning/agent-version-following-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/agent-version-following-plan.md). No code changes.

### Design decisions

* **Breaking is the default.** Every save produces a breaking version unless the save request explicitly passes `compatible=true`. Existing agent versions carry no compatibility value and stay pinned, so upgrading EDDI changes nothing.
* **A server-owned compatibility generation** (semver-major equivalent) decides which versions a conversation may move between. It is assigned by the store and never read from the config body, so copies, exports and agent sync cannot carry a "compatible" forward by accident.
* **The version is resolved on every turn** (highest ready version of the conversation's generation on this node), which covers the 10 s cluster rollout window and makes undeploying a faulty version an automatic rollback. Compatibility is therefore bidirectional by definition.
* **No new end-conversation policy**: keeping or ending conversations on an older generation uses the existing undeploy options, with a new `system:agent-version-retired` reason.
* **Undeploy counts only conversations that cannot move**, so compatible conversations neither block undeploy nor get ended by `endAllActiveConversations`.
* **The generation is a store-owned `AgentConfiguration` field.** Only the latest version can be updated (`HistorizedResourceStore.update`), so the previous version always holds the highest generation and the assignment needs no other lookup.
* **Two existing gaps found and scheduled as Phase 0**: a Slack thread whose conversation has ended fails on every further message, and a `persistent` schedule whose conversation has ended is retried until it dead-letters. Both will start a fresh conversation instead. Both already happen today (the idle sweep and `endAllActiveConversations` end conversations).

### Next

Phase 0 of the plan (ended-conversation recovery for Slack and persistent schedules), then Phase 1 (generation stored and exposed, no behaviour change).

***

## ✨ feat(manager, chat): compatible saves, deployment impact and version markers in the UIs (2026-09-29)

**Repo:** EDDI (`feat/agent-version-following-ui`) — the Manager and Chat UI half of agent version following; the backend ships in `feat/agent-version-following`. Against a backend without it, the `compatible` parameter is ignored (every save stays breaking, as today) and the impact preview hides itself when its request fails.

* **Manager — saving**: a "Compatible with the previous version" checkbox, unticked by default and reset after every save, on each save path that writes an agent version and has room for a choice: the post-save cascade dialog, the resource editor in cascade mode, the Studio editor panel, the workflow editor's Save & Test, and the Workforce agent editor sheet. Ticked on top of a version that predates generations, it says that conversations already on that version stay on it. The tick also resets when the page's agent context changes underneath it: the resource editor, the workflow editor and the Studio editor panel (agents sharing a workflow) stay mounted when only their query string changes, so a tick given for one agent could otherwise have written another agent's version as compatible. The workflow editor now also takes the agent version its next Save & Test replaces from the new context. `updateAgent` sends `compatible=true` only for an explicit `true`. Silent inline saves (agent section toggles, adding or removing workflows on the agent page) stay breaking.
* **Manager — deploying**: the agent page's Environments card shows, per environment, what the displayed version does to the conversations on the other deployed versions (`FOLLOW` / `STAY` with the reason), from `GET /administration/{env}/deploymentimpact/{agentId}`. The undeploy dialog notes that conversations a compatible deployed version can continue are moved rather than ended. A badge next to the version shows its compatibility generation.
* **Manager — conversations**: the conversation view and the debugger's memory inspector show the version each step ran on (`agent:version`), an "Agent moved from vN to vM" notice on the step after a move (`agent:switch`), and "Ended: the agent version was retired" for such an end. 16 new keys in all 11 locales. The OpenAPI contract test exempts the new endpoint until the next snapshot refresh.
* **Chat UI**: a message refused because the conversation ended (410) re-reads it; when it ended as `agent-version-retired`, the footer says "This assistant was updated. Start a new conversation to continue." next to the existing new-conversation button.
* **Not built** (tracked in the plan): a compatible/breaking marker on every entry of the version list, and a keep/end choice inside the impact preview.

***

## 🔐 feat(secrets): atomic create-if-absent in the vault SPI (2026-09-29)

**Repo:** EDDI (`feat/secrets-store-if-absent`) · closes #700

`ISecretProvider.store` is an upsert for every caller, so a read-then-write over a secret was a TOCTOU: two callers could both see a key as absent and both write, and the later write silently replaced the earlier. `AgentSetupService.useNamedVaultKey` narrowed that with a read-back after the write (#699) but could not close it.

### What changed

* **`ISecretPersistence.insertSecretIfAbsent(EncryptedSecret) → boolean`** — `false` when a row already existed. PostgreSQL: `INSERT … ON CONFLICT (tenant_id, key_name) DO NOTHING`, answering from the affected-row count. MongoDB: `insertOne`, treating a duplicate-key error as "already there". Neither needs a migration — both backends already enforce a unique `(tenant, key)` (`idx_secret_tenant_key`, and the table's `UNIQUE (tenant_id, key_name)`).
* **`ISecretProvider.storeIfAbsent(...) → boolean`** exposes it; `VaultSecretProvider` seals the value exactly as `store` does (the shared step is now one private `seal` method) but does not read the row first — the insert is the existence check. `store` is unchanged and remains the explicit upsert for rotation and `RestSecretStore`. The SPI method has no default implementation on purpose: a default read-then-write would reintroduce the race under a name that promises otherwise.
* **`AgentSetupService.useNamedVaultKey`** creates through `storeIfAbsent`. Losing the race no longer overwrites anything: the setup reuses the winner's entry when it holds the same value and fails, before creating anything, when it holds a different one. The best-effort `verifyStoredValue` read-back is gone. `docs/secrets-vault.md` is updated.

### Not done

Callers that intentionally upsert (`AgentSigningService` key generation, `PropertySetterTask.autoVaultSecret`, `RestSecretStore`) are unchanged — moving them changes what a conflict means for each and is a separate decision. The checksum reservation for `findReusableSecret` (item 4 in the issue, marked lower value) is also left: that path self-corrects.

### Tests

`MongoSecretPersistenceTest` and `PostgresSecretPersistenceTest` (Testcontainers) gain an `insertSecretIfAbsent` group: insert-when-absent, an existing row left byte-for-byte alone, tenants independent, `upsertSecret` still replacing, and eight writers racing for one key over 25 rounds with exactly one winner whose value is the stored one. With `DO NOTHING` swapped for `DO UPDATE`, the race and the leave-alone tests fail; without the unique index, the Mongo ones do. Both ITs now rebuild the Mongo persistence per test, since the collection drop between tests takes the index with it. `VaultSecretProviderGrantTest` pins that `storeIfAbsent` never reads or upserts, and `AgentSetupVaultKeyReuseTest` covers winning, losing to the same value and losing to a different one.

***

## 🔒 fix(memory): claim a global memory key atomically, so a racing agent cannot overwrite it (2026-09-29)

**Repo:** EDDI (`fix/user-memory-global-owner-cas`)

### What changed and why

When `allowGlobalKeyOverwrite` is off, the `rememberFact` tool must not overwrite a `global` memory owned by another agent. It checked ownership with a read and then wrote with a plain upsert. Those were two steps. Two agents of the same user, both allowed `global` visibility, could each read the key as free and each write it. The store kept the first agent as owner but applied the second agent's value, so one agent silently overwrote a memory the other owned (CWE-367). This was a CodeRabbit finding on #860 and was deferred there.

* **`IUserMemoryStore.upsertIfOwnedBy(entry, agentId)`** writes a global entry only if its key is free or already owned by `agentId`, and returns whether it applied. The store makes that decision inside the write itself. An existing entry with no recorded owner (null or blank) counts as not owned.
* **PostgreSQL:** one `INSERT … ON CONFLICT (user_id, key) WHERE visibility = 'global' DO UPDATE … WHERE usermemories.source_agent_id = EXCLUDED.source_agent_id RETURNING id`. A conflicting insert waits on the unique `idx_um_upsert_global` index and then evaluates the owner condition against the committed row. No returned row means the write was refused.
* **MongoDB:** the store now builds a **unique partial index on `(userId, key)` for global entries** (`idx_um_upsert_global`). Mongo had no such index before, so racing upserts could also create two global entries for one key.
  * `upsertIfOwnedBy` is an upsert filtered on the writer as owner. If another agent holds the key, the upsert attempts a second insert and the index rejects it with a duplicate-key error. That error is reported as a refusal.
  * A plain upsert that loses the same insert race retries once as an update.
  * `insertIfAbsent` that loses the race now returns `null` ("already there"), not a duplicate-key error.
  * If the index cannot be built, because a deployment already holds duplicate global keys, the store logs a WARN and falls back to a non-atomic check.
* **`UserMemoryTool.rememberFact`** uses the conditional write for global writes when `allowGlobalKeyOverwrite` is off. It returns the existing "owned by another agent" refusal when the write did not apply, and a refused write does not count against `maxWritesPerTurn`.
  * The early ownership read stays in place, only so that a doomed write does not trigger capacity eviction.
  * A global write with no agent id is now refused, since there is no owner to write as.
* **Unchanged:** REST, MCP, admin, property and `allowGlobalKeyOverwrite=true` writes keep the plain `upsert` semantics.

### Tests

* `UserMemoryToolTest`:
  * the conditional write is used for owned global writes and not for self writes or overwrite-allowed writes;
  * a lost race is refused;
  * a refused write leaves the turn budget intact;
  * a global write with no agent id is refused.
* `PostgresUserMemoryStoreUnitTest` and `configs/properties/mongo/MongoUserMemoryStoreTest` (mocked):
  * the conditional SQL and filter shape;
  * no returned row, or a duplicate key, is a refusal;
  * other write errors propagate;
  * the index options;
  * the no-index fallback;
  * the plain upsert's duplicate-key retry.
* `datastore/postgres/PostgresUserMemoryStoreTest` and `datastore/mongo/MongoUserMemoryStoreTest` (Testcontainers):
  * free, own, other-agent, ownerless and blank-owner keys;
  * 8 agents racing for a fresh key over 10 rounds, with exactly one winner whose value and ownership stick.
* The Mongo IT also checks that the index exists and that 8 concurrent plain upserts leave one entry.

**Files:** [`IUserMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/IUserMemoryStore.java), [`PostgresUserMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserMemoryStore.java), [`MongoUserMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java), [`UserMemoryTool.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/UserMemoryTool.java)

***

## 🐛 fix(memory): MongoDB `usermemories` enforces its upsert identities (2026-09-29)

**Repo:** EDDI (`fix/usermemories-unique-identity`) · closes #892

`MongoUserMemoryStore` keyed its upserts on two identities — one shared entry per `(userId, key)` among `global` entries, one per `(userId, key, sourceAgentId)` among the non-global ones — but no index enforced either. Two first writes racing for the same identity both missed the filter and both inserted, and every later upsert then updated whichever copy the server found first. That held for ordinary `upsert` and `mergeProperties` writes and for `insertIfAbsent`, which the legacy `PropertiesMigrationService` uses: #871 stopped it replacing newer values, but two nodes migrating at once could still insert the same entry twice. PostgreSQL has enforced the same identities since the store was written (`idx_um_upsert_global`, `idx_um_upsert_agent`).

### What changed

Since this branch was opened, #893 landed the same global index (`idx_um_upsert_global`) and a duplicate-key retry for its owner-checked writes, but left two gaps: it could not be built over data that already holds duplicates (the store then fell back to a non-atomic ownership check), and nothing enforced the per-agent identity. This change keeps #893's write path as it is and closes both.

* **`UserMemoryIdentityIndexes`** (new) runs at startup, before the store's own global index build. While either index is missing it merges duplicates, then installs both partial unique indexes: `(userId, key)` filtered to `visibility: global` — the same name and spec #893 builds, so the two never conflict — and `(userId, key, sourceAgentId)` (`idx_um_upsert_agent`) filtered to `visibility: {$in: [self, group]}`; a partial filter accepts `$in` but not `$ne`.
* **Merge.** Per duplicated identity the entry with the newest `updatedAt` survives (compared as parsed instants: `Instant.toString()` has a variable-length fraction, so string order is wrong) and takes the summed `accessCount`. Once both indexes exist the scan is skipped. A duplicate that another node inserts between the merge and the build triggers one more pass. Any failure is logged and startup continues, so #893's `globalKeyUnique` fallback still applies exactly as before. With the merge in place that fallback becomes the exception rather than the rule for upgraded deployments.
* **No new write-path code.** The duplicate-key retry and the `insertIfAbsent` "already present" answer are #893's; this branch originally carried its own and dropped it in favour of that one on merge.

### Tests

`datastore/mongo/MongoUserMemoryStoreTest` (Testcontainers, `mongo:6.0`) gains a `Unique upsert identities` group: eight writers racing for one identity, 25 rounds each, for global upserts, per-agent upserts (self and group mixed), `mergeProperties` and `insertIfAbsent`; the startup merge; and the indexes refusing a duplicate written around the store. With the startup call removed, six of the seven fail. The class now rebuilds the store after each collection drop, so all its tests run under the new indexes. The two mock-based store tests stub the index listing (`IdentityIndexStubs`).

***

## 🐛 fix(migration): a 5.x database now boots on 6.5 with its agents deployed, its conversations intact and no tokens in memory (2026-09-29)

**Repo:** EDDI (`fix/v6-first-boot-blockers`)

### Why

We rehearsed the upgrade against a restored production 5.5.1 database with both v6 migration flags on. Every deployed agent ended in ERROR, every old conversation loaded with empty steps, and the properties migration copied 159 learners' platform bearer tokens into `global` long-term memory. There were four separate causes.

### What changed

**1. A unique index from before 6.3 no longer stops the descriptor store from starting.** Databases created before 6.3 hold `descriptors.resource_1` with `unique: true`. 6.x asks for the same key non-unique, and MongoDB refuses with `IndexKeySpecsConflict` (86). The exception escaped the store's constructor, so no agent could deploy and `/descriptorstore` answered 500. [`MongoResourceStorage.ensureIndex`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java) now reads the collection's indexes, following the same approach as the deployment store (#781):

* An index on the same key with another specification (a different `unique` flag, or partial, sparse, hidden, or with a collation other than the collection's default — none of which serves this store's queries) is dropped and rebuilt to the current specification. That includes one under another name: the server accepts a non-unique index beside it without any error, and the unique one would go on refusing writes.
* An equivalent index under another name (85) is kept.
* An index that holds the generated name on a *different* key is never dropped; that case is logged, the stale index on our key is still replaced, and ours is built as `<name>_eddi`.
* A conflict that can't be resolved is logged at ERROR, and the store starts anyway.

The index is rebuilt non-unique on purpose. Since 6.3, neither backend enforces uniqueness on `resource`: PostgreSQL's expression index isn't unique, and a MongoDB created by 6.3 never had it.

**2. The LLM workflow step type is renamed.** A v5 workflow names its LLM step `eddi://ai.labs.langchain`. The rename migration's URI rewrites end in `/` so that they match config URIs only, so the bare step type never matched, and every agent with an LLM step failed with `Extension 'ai.labs.langchain' not found`. The slash can't simply be dropped: `ai.labs.behavior` and `ai.labs.httpcalls` are still the registered step ids, even though their config URIs moved. [`V6RenameMigration.STEP_TYPE_REWRITES`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java) is an exact-match table for step `type` fields in `workflows` and `workflows.history`. Its only entry is `langchain → llm`, the one v5 step type (of seven in the database) that 6.x doesn't register. `V6RenameMigrationStepTypeTest` builds the real extension registry by running every bootstrap module, and checks both sides of the table against it. [`LlmModule`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/bootstrap/LlmModule.java) also registers `ai.labs.langchain` as an alias of the LLM task, as `ai.labs.behavior`/`ai.labs.rules` already are. The migration rewrites step types only on the boot that runs it: a database migrated by 6.0–6.4 recorded the migration complete with the old type still in place, and a v5 ZIP imports it verbatim. The test requires any v5 type that stays registered to be an alias of the same task.

**3. Old conversations keep their history.** EDDI 5 stored a step's runs as `conversationSteps[].packages`. The 6.x snapshot serialises them as `workflows` and ignored the unknown key, so a v5 conversation loaded 200/READY with no data in any step, and the next save wrote the empty steps back. There are two fixes:

* The migration renames the stored shape server-side: one `updateMany` with an aggregation pipeline, because `$rename` can't reach array elements. It covers `conversationSteps` and `redoCache`, and also renames `originPackageId` to `originWorkflowId` inside each result. It matches per array element — a step that holds `packages` and whose `workflows` is absent, null or empty — so a second run is a no-op. A step that holds both keys with a non-empty `workflows`, or neither key, is left unchanged and counted in a WARN.
* [`ConversationMemorySnapshot`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java) accepts both old keys on read (`@JsonAlias`), as a safety net for documents the migration hasn't reached.

**4. Credentials are not migrated into long-term memory.** [`PropertiesMigrationService`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/mongo/PropertiesMigrationService.java) now leaves two kinds of key behind:

* the keys in the new `eddi.migration.properties.skip-keys` (default `userInfo`, the 5.x per-request identity: a token next to names and ids);
* any key whose value [`SecretScrubber.containsCredential`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/sanitize/SecretScrubber.java) flags. That check uses the export redaction rules unchanged, and doesn't modify anything. Every field name at any depth, lists inside lists included, is also checked on its own, since the scrubber judges `apiKey: {value: "…"}` by the name `value`. BSON `ObjectId` values are identifiers by type and are left out of it; as extended JSON their hex string would trip the entropy rule.

Skipped keys are logged by name and count, never by value, and they aren't failures: the values stay in `properties_migrated_v6`. [`user-memory.md`](/architecture-and-concepts/user-memory.md) documents the cleanup query for a database that already ran the old migration.

### Decisions

* `resource_1` is rebuilt **non-unique** rather than kept unique; see 1.
* The scrubber's entropy rule is kept for the properties migration. On the rehearsal database it also skips five 17-character `createdProgram.courseId` values. That is accepted: they stay in the backup collection, and weakening the check for `*Id` fields would weaken it for session ids too.
* The step-type table lists only the types that no longer resolve. A type that is still registered is never rewritten, even when its config URI moved.

### Tests

`MongoResourceStorageIndexConflictTest`, `V6RenameMigrationStepTypeTest`, `V6RenameMigrationFirstBootTest` (Testcontainers MongoDB, read back through the production Jackson codec, including a save round trip), and new `PropertiesMigrationServiceTest` cases that use zero-entropy fake credentials. Each was mutation-checked: with the fix reverted, the tests fail, and with each half of the conversation fix removed on its own, the other half still keeps the load working.

### Next

PR B covers readiness with agents in ERROR plus ERROR retry, the nested Thymeleaf concat, and migrating triggers and user conversations. PR C covers the retention sweep on first boot, the startup probe, the documentation fixes, a 5.x → 6.x upgrade guide, and moving the conversation environment pass server-side.

***

## 📝 docs(planning): mark the operator follow-ups plan as a completed record (2026-09-21)

**Repo:** EDDI (`docs/operator-followups-plan`)

### What changed and why

Review follow-up on [#686](https://github.com/labsai/EDDI/pull/686). The document was written as a hand-off for three unimplemented tasks; all three have since landed, so a cold reader was being told to do work that already exists.

* **Status block rewritten.** It said "all three are unimplemented". A landed in `c031cc8c95` (`ui/manager/src/lib/operator/tool-scopes.ts` now carries the test-drive endpoints), B in `5550010a3d` (`use-operator-chat.ts` `hydrate()` plus `components/operator/operator-history.tsx`), and C in `08e76415cc` (`HttpCallToolsProvider` refuses a non-JSON `requestBody` before sending). The estimates are kept so they can be judged against what landed, and the block now says to read the body's "today" / "what exists today" as the state at the time of writing.
* **Task C's summary row contradicted its own section.** The table said the body "fails at the API, not before" — the inverse of §C's title and of C.3, which both require refusing the call *before* it is sent. Corrected.
* **The B test list overclaimed what a snapshot carries.** `SimpleConversationMemorySnapshot` has `conversationState`, `hitlPausedAt`, `hitlPauseType` and `hitlPendingToolCalls` — but no pause *reason*. The bullet now says the reason comes from `useApprovalStatus`, which the restored `isPaused` is what enables, matching §B.3.3.

**Files:** [`planning/operator-followups-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/operator-followups-plan.md)

***

## 🔎 docs(planning): monorepo migration plan — Revision 3, re-verified against all three repos (2026-09-15)

**Repo:** EDDI (`chore/monorepo-migration-plan`, `origin/main` @ `b2310a6ed` merged in — 391 commits, one conflict in this file). Plan only; no code moved.

**Why now.** The plan (PR #670) was written 2026-08-11 and never merged; its branch had fallen 391 commits behind and the changelog conflicted. Before anyone implements it, every fact it rests on was re-checked against the current `main` of EDDI, `EDDI-Manager` and `EDDI-Chat-UI`. The merge conflict was resolved by taking `main`'s live changelog and filing the original 2026-08-11 entry into `docs/changelog/2026-08.md` next to the other entries of that date — the rotation had already archived its siblings, so the live file would have held it out of order.

**Verdict: still the right move, and the case got stronger.** Recorded in a new §0 of `planning/monorepo-migration-plan.md`; the body was corrected in place. The three facts that matter: (1) committed generated output grew from 562 to **738** files, synced by **18** hand-run `chore: update Manager UI assets (Manager@…)` commits in five weeks; (2) **the Manager's backend E2E on `main` has been red on 15 consecutive runs since 2026-08-26** (`resources-crud.fullstack`: "Rules was created but never appeared in rulestore/rulesets/descriptors"), while every Manager PR stays green because PRs run the MSW tier only — the one cross-repo signal that exists is permanently red and nothing makes anyone own it; (3) the Manager grew a second hand-synced pointer, `openapi-operations.json`, a snapshot of EDDI's OpenAPI operations refreshed by hand against a running backend, last refreshed 2026-08-25 with 14 `IRest*` interface commits since. Alternatives (build the backend image inside `e2e.yml`; publish per-SHA tags) were considered again and rejected: they fix staleness, not attribution — a backend PR still cannot run the Manager's tests before merging.

**Phase-0 scans run for real, not predicted.** Manager: `npm audit --omit=dev` clean, Trivy clean. Chat: **2 high** (npm audit) / **6 HIGH** (Trivy, via `docker run aquasec/trivy` since neither Trivy nor gitleaks is installed) — all `react-router 7.13.1`, all fixed by 7.18.2. Importing the Chat lockfile unfixed would fail `dependency-review` on the PR *and* trip EDDI's `exit-code: 1` Trivy gate on the first push, which gates `docker` and therefore every release. One bump in the Chat repo before the subtree import is the only concrete blocker found. The multi-page Vite dry-run (V2) was **re-executed** on an isolated copy of Manager `main` @ `0870ae87`: still works, three shells share `main-<hash>`, auth-config and loader preserved, no stray `index.html`; the bundle is now code-split (4.3 MB `editor-registry` chunk), which the shipped-shell gate already covers.

**Facts that changed and where the plan was wrong.** `EDDI-Chat-UI`'s default branch is **`master`**, so §5.1's `git fetch chat-origin main` would have failed outright. Manager `public/` no longer ships `logo_eddi.png` (the root copy in the backend is referenced by nothing; now deleted in §6.4). Manager `vite.config.ts` already has a `build` block (`assetsInlineLimit` for woff2 under `font-src 'self'`) and a `define` — §6.2 now merges instead of adding. `.github/scripts/` holds **five** helper scripts, not one, and CI also runs `i18n:check`; a Stryker `mutation.yml` exists (deferred to §13). `ui/manager/.claude` is deliberately tracked now (three skills + `launch.json`) — §5.2 no longer untracks it wholesale. Manager and Chat use **Renovate** with automerge, EDDI uses Dependabot — §9.1 now demands the decision before adding a second bot. `ci.yml` gained `redhat-publish`, which reads `needs.docker.outputs.is-stable` — §8.6's re-export list was incomplete. The workflow-level concurrency group §8.8 wanted to add already exists. The GitHub-managed CodeQL surface now also runs `Analyze (python)`. Pack growth is \~104 MiB / 1,275 commits, not \~90 MiB / 1,079. The Chat's in-flight `fix/release-6.2-polish` (two commits, pushed 2026-07-27) is still unmerged and never shipped — the committed bundle is from `master` @ `71fa395`.

**Second pass, same day — two findings changed the plan.** (1) `git subtree add` was tested in a throwaway repo and **does not preserve path-scoped history**: after the import, `git log -- ui/manager/<file>` shows only the merge commit and `--follow` shows nothing; only `git blame` and the un-scoped log survive. Rewriting each history into its subdirectory with `git filter-repo --to-subdirectory-filter` (installed locally) and merging with `--allow-unrelated-histories` preserves all three, at the cost of new SHAs — acceptable, since the archived repos keep the originals and nothing here references them. §2's decision is changed, §5.1 carries the new recipe with `--no-tags` and post-import assertions, and §4.3 now scans the *rewritten* clones so gitleaks fingerprints stay valid. (2) **The Manager's red backend E2E was reproduced in Docker: `labsai/eddi:latest` exits(1) under the Manager's compose files** because `HighValueSurfaceGuard` (2026-08-21) also needs `EDDI_MCP_ALLOW_UNAUTHENTICATED` and `EDDI_SECRETSTORE_ALLOW_UNAUTHENTICATED`; the compose sets only the security opt-out, which the guard's Javadoc says does not cover it. With the two variables added, the backend boots and the previously failing descriptors assertion passes — the whole 15-run streak is two env vars that a same-repo change would have carried. New §4.7 makes that a preparatory Manager PR. Smaller corrections: full `npm run build` measured at 112 s (§7.4 budget), Node pin bumped to v20.20.2, `maven-resources-plugin` 3.3.1 is already managed so its version is dropped, a `maven-clean-plugin` fileset now deletes stale pre-migration generated files from `src/main/resources` (Quarkus dev mode watches that tree; §7.3 has the check), `ui/chat` gets its own `.editorconfig`, the `ui` path filter includes `ci.yml`, `CODEOWNERS` needs no change, and the migration PR is too large for CodeRabbit/Copilot so the reviewable surface is listed for a human.

**Resolved after pushing.** The §10 question is answered: with `Build & Test` skipped and `CodeQL Analysis` green, #670 went `BLOCKED` → `UNSTABLE` → `CLEAN` — a job-level skip satisfies a required context, so the path-gated jobs can be required directly (the aggregator stays for `needs` edges only). **CodeRabbit round 3 (6 comments, all addressed):** Chat baseline used `origin/main` (→ `master`); a stale `v20.19.0` in §7.4; the `databases` matrix needs a job-level `outputs:` mapping in `detect-changes`, not just a step output; the shipped-shell gate followed only `/assets` and `/scripts` — it now collects every same-origin `src`/`href` from the four shells plus every `url()` in their stylesheets (481 URLs on the current image, verified against a running instance in \~50 s) and asserts the MSW worker and root logo are 404; §7.3 asserted a Manager-owned root logo that no longer exists (now `img/logo_eddi.png` from Chat); and the stale-file excludes, clean fileset and `.gitignore` block now carry one identical list including `mockServiceWorker.js`, icons, fonts and `img/`. Reframed why those lists exist at all: a plain `git pull` deletes formerly tracked files, so the real risk is a stale local deploy script re-creating them, ignored and invisible.

***

## 🐛 fix(migration): failed deployments are retried and visible; triggers and user mappings migrate; unconvertible templates are reported (2026-09-29)

**Repo:** EDDI (`fix/v6-first-boot-data`)

### Why

This is the second half of making a 5.x → 6.5 first boot work without manual steps, after `fix/v6-first-boot-blockers`. The rehearsal against a restored production 5.5.1 database found three more problems. An agent that failed to deploy stayed in ERROR until a restart, while readiness said UP. Managed conversations by intent found no trigger, and existing users' intent mappings pointed at no agent. And one legacy template was silently rewritten into literal text.

### What changed

**Failed deployments are retried, and readiness says which agents are in ERROR.** `deployAgent` reports a workflow that can't be built by leaving the agent in ERROR and returning normally. [`AgentDeploymentManagement.checkDeployments`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java) recorded that deployment as handled, and never looked at it again. A deployment is now recorded only once the registry reports it READY; an outcome that isn't known yet (another caller holds it IN\_PROGRESS) is simply looked at again on the next sweep. A failed one (ERROR, or an exception) is retried after 10 s, then with a doubling delay capped at 5 minutes, for as long as its record says it is deployed. It is logged at ERROR once when it starts failing and at INFO when it recovers, not on every retry. [`AgentsReadinessHealthCheck`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/readiness/AgentsReadinessHealthCheck.java) counts the failing deployments in its data (`agentsInErrorCount`) and **stays UP**. It reports only the count because `/q/health` is unauthenticated; the ids are in the log and in the authenticated deployment-status API. Readiness routes traffic for the whole instance, and one misconfigured agent must not take every replica out of rotation. The sweep now also stays parked until **every** startup migration has run, not only the rename. Before, the tick after the rename could deploy agents while the startup thread was still converting their templates, and those agents kept their Thymeleaf until a restart. If the startup path throws before it can grant readiness, the first sweep that completes grants it. The sweep is also serialized, because `SKIP` only stops one tick overlapping the next and the startup path calls the sweep itself. The #781 behaviour is unchanged: the sweep is parked while the rename migration is pending, and deferred readiness is granted by the first sweep after it.

**Triggers and user-conversation mappings are migrated.** [`V6RenameMigration`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java) now renames `bottriggers` → `agenttriggers` with the other collections, and rewrites each trigger's `botDeployments[].botId` to `agentDeployments[].agentId`, with `unrestricted`/`restricted` becoming `production`. If the remap makes two entries of one trigger identical, they're collapsed into one and logged. The managed endpoint picks an entry at random, so a duplicate would double that agent's share. Entries that still differ are kept: which one was meant can't be told. `userconversations` gets `botId` → `agentId` and the same environment remap in one server-side `updateMany`. A mapping that already holds a different `agentId` is left unchanged and reported. Neither collection's unique key (`intent`; `intent, userId`) is touched, so nothing can collide. The v5 environments are matched ignoring case, as the per-document pass did. The v6 models also accept the v5 field names on read (`@JsonAlias`). A database whose rename migration an earlier 6.x already recorded gets the same trigger and mapping passes on its next boot with the flag on, since before 6.5 they didn't exist. A failed count of `bottriggers` is not taken for an empty collection.

**Templates the converter can't convert safely are left alone and reported.** A template like `[['[[${'+'x.x'+'}]]']]`, an expression whose output is itself Thymeleaf, was rewritten to `[['+'x.x'+']]` and counted as migrated. [`TemplateSyntaxMigrator.unconvertibleReason`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/TemplateSyntaxMigrator.java) now refuses any string literal inside `[[…]]` or `[(…)]` that contains an expression opener (`[[`, `[(`, `${`), and `migrate` returns such input unchanged. A lone closer such as `']]'` is only text and still converts. A literal holding `{` is written into a Qute unparsed block (`TemplateEscaping.unparsedBlock`), so `'{' + 'name' + '}'` still prints `{name}` instead of becoming a Qute expression. [`V6QuteMigration`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java) leaves the whole document untouched, logs a WARN with its collection, id and the path of each field, and doesn't mark the migration complete. It also counts, as the same kind of failure, any string that still holds a Thymeleaf expression delimiter (`[[${`, `[(${`, `[# th:`) after conversion. That check deliberately doesn't match text that merely mentions `th:if`, and it accepts a directive opener with any whitespace after `[#`, as the conversion patterns do. The ZIP importer judges the decoded string values of a resource, because the escaped quotes of the JSON text hide where a literal ends. If a value is refused, it imports the resource without converting it and logs a WARN. The check only runs when the resource holds Thymeleaf at all. The existing test that required this shape to lose its `[[${` described the defect, so it now requires the shape to be refused.

### Decisions

* **Readiness stays UP with agents in ERROR** (option (a) of the handoff). The list is in the check's data, and the retry makes a transient failure heal without a restart. DOWN-on-any-ERROR would let one agent take down a fleet.
* A failed deployment is retried **indefinitely at a capped rate** rather than a fixed number of times. A cause fixed hours later (a vault secret, an index) should still heal without a restart.
* An unconvertible template keeps the Qute migration **incomplete**, so it runs again on the next boot until the document is fixed. That matches how the migration already treats a document it can't write.

### Tests

`AgentDeploymentManagementRetryTest` (with a steppable clock: ERROR then READY, persistent ERROR backing off exactly 10/20/40/…/300 s, undeployed while failing, exception path, readiness data), `V6RenameMigrationTriggersTest` (Testcontainers: trigger read by intent through `AgentTriggerStore`, user mapping read through `UserConversationStore`, dedupe, an ambiguous mapping, idempotence, the read alias), `V6QuteMigrationUnconvertibleTest`, and parameterised `TemplateSyntaxMigratorTest` cases. Each was mutation-checked: with each part reverted, at least one of its tests fails.

***

## 🐛 fix(upgrade): the 5.x first boot deletes no conversations, survives its probes, runs its conversation pass server-side, and is documented (2026-09-29)

**Repo:** EDDI (`fix/v6-first-boot-ops`)

### Why

These are the operational hazards of a 5.x → 6.x first boot, found in the same review as `fix/v6-first-boot-blockers` and `fix/v6-first-boot-data`. Four of them:

* The first boot permanently deleted every ended conversation older than a year.
* The shipped startup probe killed the pod mid-migration, so the migration never finished.
* The slowest pass could overwrite concurrent writes.
* The documentation was wrong in two places and had no upgrade guide at all.

### What changed

**No retention deletes on a database from EDDI 5 until the operator decides.** EDDI 5 kept ended conversations for ever; 6.x defaults to 365 days, and the daily sweep starts at boot. [`RestConversationStore`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/rest/RestConversationStore.java) now deletes nothing while [`V6RenameMigration.holdsRetention`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java) is true: the migration is pending, it has completed (a `v6-rename-retention-hold` marker in `migrationlog`), or the v5 collections still hold documents. Instead it logs daily how many ended conversations it would have deleted. The operator decides with `eddi.migration.v6-rename.retention-confirmed=true`, or keeps them with a retention of `-1`. A first version held only for the process that ran the migration; review pointed out that a second replica or a rescheduled pod would then delete minutes later, so the hold is now durable.

**The startup probe checks liveness.** Readiness is DOWN for as long as the migrations run, and a startup probe on readiness gave the pod 60 s. On a throttled database the migration took 24 minutes, so the pod crash-looped, and each restart began the migration again. The chart ([`deployment.yaml`](https://github.com/labsai/EDDI/tree/main/helm/eddi/templates/deployment.yaml)), `k8s/base` and `k8s/quickstart.yaml` now probe `/q/health/live` at startup. The chart makes path, `failureThreshold` and `periodSeconds` configurable under `eddi.startupProbe`. Chart `2.2.0 → 2.3.0`. `DeploymentManifestsTest` and the CI chart render check both cover it.

**The conversation pass is server-side and bumps the revision.** `V6RenameMigration` renamed `botId`/`botVersion` and the environment in every conversation by reading each document into Java and replacing it whole. That cost about 20 of the 24 minutes on a throttled database, and could overwrite a concurrent write. It is now one `updateMany` with an aggregation pipeline, and it bumps `_rev`/`_histRev` the way a store write does, as does the step-shape pass from `fix/v6-first-boot-blockers`. A writer holding a pre-migration copy now gets the store's ordinary conflict instead of silently reverting the migration. Legacy `eddi://` URIs inside stored conversation step data are no longer rewritten: no 6.x code reads them, and finding them means reading every document in full. Deployment rows keep the per-row pass, because a row can collide with another on its v6 key.

**Documentation.**

* [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md): `eddi.migration.backupBeforeWrite` writes `<collection>.premigrationbackup`, and only for the legacy-format step, not `.history` for every migration.
* [`output-templating.md`](/agent-configuration/output-templating.md): templates are converted only with `eddi.migration.v6-qute.enabled=true`.
* `eddi.migration.v6-rename.enabled` is now in `application.properties`.
* The new [`upgrading-from-5x.md`](/deployment-and-infrastructure/upgrading-from-5x.md) covers backup, both migration flags, retention, probes, what the first boot does, how to check it, and what isn't migrated. It is linked from the README and `SUMMARY.md`.

### Decisions

* **Retention holds until the operator confirms**, not for a boot or a fixed time. Never deleting is what EDDI 5 did, so the hold costs nothing that existed before; deleting is irreversible.
* The startup probe moved to **liveness** rather than getting a longer readiness budget. No budget fits every database, and readiness still keeps traffic away until the agents are deployed.

### Tests

`RestConversationStoreTest` (held for a database from EDDI 5, released by the confirmation, runs without EDDI 5 history), `V6RenameMigrationFirstBootTest` (server-side field rename and revision bump, a stale pre-migration write refused, an ambiguous `botId`+`agentId` document left alone, the durable retention hold), and `DeploymentManifestsTest.startupProbeChecksLiveness`. Each was mutation-checked.

***

## ✨ feat(manager): Claude 5.5 and GPT-6 Sol/Luna in the model autocompletion (2026-09-29)

**Repo:** EDDI (`feat/manager-model-suggestions-5-5`)

### Why

The model field in the agent wizard and the Platform Operator activation form offers suggestions from [`model-suggestions.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/model-suggestions.ts). It stopped at Claude Opus 5 / Sonnet 5 and the `gpt-5.6-*` OpenAI ids, so the newest releases had to be typed by hand.

### What changed

* `anthropic`, `gemini-vertex`: added `claude-opus-5-5` and `claude-sonnet-5-5`, listed first.
* `bedrock`: added `global.anthropic.claude-sonnet-5-5` (on `bedrock-runtime` the bare model id is not accepted for on-demand inference; the `global.` inference profile is) and `anthropic.claude-opus-5-5` (kept bare: its inference-profile id is unconfirmed).
* **Default model** is now `claude-sonnet-5-5` (was `claude-sonnet-5`) wherever the Manager fills one in: the wizard provider table (`anthropic`, and `global.anthropic.claude-sonnet-5-5` for Bedrock), the group wizard fallbacks, the Operator's default config, and the conversation-summary and Dream model defaults. The `e.g. claude-sonnet-5` placeholders (`llmEditor.cascadeModelName`, `Workforce.wizard.modelPlaceholder`) follow, in all 11 locales.
* `openai`: added `gpt-6-sol` and `gpt-6-luna` after `gpt-6-astra`.

### Decisions

* Gemini already led with `gemini-3.8-flash` (stable GA 2026-09-02); no Gemini 3.8 Pro exists, so nothing added.
* No Haiku 5.5: announced 2026-09-22 but no model id published yet.
* `azure-openai` untouched: it lists Azure deployment names, and GPT-6 availability there is unconfirmed.
* No xAI provider in the Manager, so Grok 4.7 is not offered; DeepSeek-V4.1-Flash not added (id unconfirmed).
* Suggestions are hints only; any custom model id can still be typed.

***

## 🐛 fix(manager): the NEGOTIATION preset drift test failed on every Windows checkout (2026-09-29)

**Repo:** EDDI (`fix/negotiation-test-crlf`)

### What changed and why

`hitl-config-negotiation.test.ts` checks that the Manager's arbitration prompt matches `DiscussionStylePresets.TEMPLATE_ARBITRATION` verbatim, by reading the `.java` file and parsing the text block per JLS 3.10.6. The parser split on `\n` only. A Windows checkout (`core.autocrlf`) has CRLF line terminators, so every line kept a trailing carriage return, a line-end `\` continuation was no longer at the end of its line, and the lines were never joined — the test failed on Windows while passing on Linux CI.

The parser now normalises CR and CRLF to LF before anything else, which is what javac itself does (JLS 3.10.6 step 1). A new case feeds it a CRLF source directly, so the behaviour is pinned on every platform, not only on Windows; removing the normalisation fails it and the real drift check.

**Files:** [`hitl-config-negotiation.test.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/__tests__/hitl-config-negotiation.test.ts)

***

## feat(llm): first-class OpenAI-compatible providers (2026-09-29)

**Repo:** EDDI (`feat/openai-compatible-providers`)

### Why

Reaching xAI, DeepSeek, Kimi, Qwen, GLM, MiniMax, OpenRouter or Groq meant knowing to use `type: openai` with a hand-typed `baseUrl`, and knowing each vendor's reasoning quirks. None of that was discoverable in the Manager or the setup tools.

### What changed

* **One catalog, one builder.** [`llm/openai-compatible-providers.json`](https://github.com/labsai/EDDI/tree/main/src/main/resources/llm/openai-compatible-providers.json) declares the eight providers (`xai`, `deepseek`, `moonshot`, `qwen`, `zhipu`, `minimax`, `openrouter`, `groq`): endpoint, regions, default model, suggestions, parameter defaults, extra body fields and capabilities. `OpenAiCompatibleProviders` loads and validates it (duplicate id, blank URL/model, non-https URL fail startup); `LlmModule` registers one `OpenAiCompatibleLanguageModelBuilder` per entry, each delegating to `OpenAILanguageModelBuilder`.
* **Precedence.** Explicit `baseUrl`, then the `region` parameter, then the provider default. `modelName` falls back to the provider default. A missing `apiKey` fails with a message naming the provider and a `${vault:<id>-key}` example.
* **`OpenAILanguageModelBuilder`** now reads `returnThinking` / `sendThinking` (default false, so `type: openai` is unchanged) and has public `build`/`buildStreaming` overloads that take extra request-body fields. They are public because the delegate is reached through a CDI client proxy.
* **Provider defaults.** DeepSeek and Kimi run with `returnThinking` and `sendThinking` true, because they reject a tool-loop follow-up that omits the reasoning of the previous call. GLM and MiniMax also set both: Z.ai asks for the historical `reasoning_content` back during tool use, and MiniMax (which the preset sends `reasoning_split: true`) puts thinking into `reasoning_content` and wants the full assistant message appended to the history. Qwen sets neither. `returnThinking` only matters together with `sendThinking` today, since EDDI does not read a response's thinking for any other purpose. A blank parameter counts as not set, so the preset default still applies.
* **Capabilities follow the catalog.** `JsonResponseFormatPolicy` and `ModelCapabilityService` read each provider's JSON-mode and vision-model tokens from it (DeepSeek's vision is `deepseek-flash`/`deepseek-v4-flash`; a unit test checks that no vision token matches a known text-only model such as `qwen3.7-max` or `glm-5.3`). `AgentSetupService.resolveParams` no longer defaults these providers to `claude-sonnet-4-6`; it uses the provider's own default model. The `setup_agent` and `create_api_agent` MCP tools name the eight ids, and their model example is now `deepseek-flash`.
* **Manager.** A grouped provider picker (model labs, OpenAI-compatible providers, cloud platforms, local) replaces the flat lists in the agent wizard, the operator activation form and the LLM editor (task type, cascade steps and judge, summary provider). Providers with more than one endpoint get a region select in the wizard and operator form: a non-default region fills the base URL, the default region clears it, and the backend `region` parameter stays for hand-written configs. A type the Manager does not know renders as " (custom)" instead of being rewritten. `llm-provider-catalog.ts` mirrors the backend JSON, and `llm-provider-catalog.test.ts` reads that JSON and fails on any drift. Twelve i18n keys in all 11 locales.
* No new dependencies and no migration: existing `type: openai` + `baseUrl` configs behave as before.

### Verification

Endpoints and model ids were checked against vendor documentation on 2026-09-29. Notes: Qwen's `us` region exists (`dashscope-us.aliyuncs.com`); Alibaba's docs now steer the Singapore and Beijing regions towards workspace-specific hosts, while the shared `dashscope-intl` and `dashscope` hosts remain the defaults here, so a workspace URL goes in `baseUrl`. Groq announced the retirement of `llama-3.3-70b-versatile` (2026-08-16), so it is not suggested.

### Known limitations

* Vendors retire models quickly; defaults were verified on 2026-09-29 and `modelName` should be set explicitly in production. Alibaba's docs now favour workspace-specific hosts for Singapore and Beijing; the shared `dashscope-intl` / `dashscope` hosts are kept as region defaults.
* HITL: a paused tool call round-trips the assistant message's `thinking` (a new `ChatTranscriptCodecTest` case is a characterisation test that pins this existing behaviour), so DeepSeek and Kimi tool turns resume correctly. Only a gating message above its 64 KB cap loses its thinking on the degraded resume, which those two providers can reject with a 400. Documented in [`langchain.md`](/agent-configuration/langchain.md).
* Local baseline: `hitl-config-negotiation.test.ts` fails on a Windows checkout because the Java text block it compares against has CRLF line endings; it is unrelated to this change.

### Review follow-up (backend)

* **Catalog corrections.** xAI gains an `intl`/`us` region pair (the US host serves `grok-4.7` and `grok-4.6` only, at about a 10% premium) and `grok-4.6`. DeepSeek's default is now `deepseek-flash` (V4.1 Flash, 2026-09-10, multimodal). Moonshot's key URL is the console page and `kimi-k2.7-code` is vision-capable. Qwen drops the legacy `qwen3.7-max` / superseded `qwen3.6-flash` suggestions, its vision tokens no longer match the text-only `qwen3.7-max`, and it sets no thinking defaults. Z.ai GLM sets both thinking flags and lists `glm-4.6v` and `glm-5.3-flash` for vision (`glm-5v` is unconfirmed). MiniMax's `cn` region is `api.minimax.cn` (the old host 302-redirects, which can drop the POST body/Authorization) and it sets both thinking flags. OpenRouter's `openrouter/auto` is vision-capable. Groq moves to `qwen/qwen3.8-27b` (`qwen3.6-27b` was shut down 2026-09-14). Sources: the vendor docs linked from [`langchain.md`](/agent-configuration/langchain.md).
* **Default model outside the builder.** `AgentSetupService.defaultModelFor` is the one rule (preset default, else `claude-sonnet-4-6`); `resolveParams` and `CreateSubAgentTool` (including its `allowedModels` guard) use it. `LlmTask.resolveModelName(params, type)` and the cascade executor fall back to the preset default too, so vision forwarding and the audit/cost records see the real model instead of null or the type string. `McpSetupTools` no longer calls `claude-sonnet-4-6` the default for every provider.
* **Blank values** in the task parameters no longer block a preset default (`"sendThinking": ""`).
* **Catalog records** ignore unknown JSON properties, and a null key or value in `parameterDefaults`/`customParameters` fails with a message naming the provider and field. `OpenAiCompatibleProviders.isCompatibleProvider` (test-only) was removed.
* **Tests.** The builder test now asserts custom parameters and thinking flags reach the langchain4j model, a new `LlmModuleTest` covers registration and the id-collision guard, and a unit test checks vision tokens against a list of known text-only model ids.

### Review follow-up (Manager)

* **The catalog mirror** follows the backend corrections above (xAI regions, DeepSeek default, MiniMax `cn` host, refreshed suggestions and Moonshot key URL).
* **No key crosses vendors.** The agent wizard clears the API key on any provider change, as the operator form already did.
* **The operator remembers its endpoint.** `OperatorConfig` gained an optional `llmBaseUrl`, so reconfiguring seeds the region select from it instead of silently falling back to the default region; the review step lists the endpoint when one is set. Any provider switch in the operator form now clears the base URL (ollama's `http://localhost:11434` used to stay in a hidden field and be submitted with openai), and switching back to the stored provider restores it.
* **Consistent provider pickers.** The group wizard (member and moderator), the workforce team builder and the group advanced editor's summarizer now use `ProviderSelect`, which gained `leadingOptions` (for their "None" / "Workforce default" choices), `ariaLabel` and a "Select a provider" placeholder for an empty value. The wizard label is bound to its select. `ProviderRegionSelect` takes a `className` and `hideLabel`, so the operator form renders it inside its own field chrome with matching `h-10 rounded-md` styling; its custom option reads "Custom URL (set below)". `LLM_PROVIDERS` is type-checked against `ProviderGroup` with `satisfies`.
* **LLM editor.** The endpoint hint resolves in the backend's order (`baseUrl`, then the `region` URL, then the default); a comment records that changing the type deliberately keeps the parameters, which stay visible in the grid.
* Four new i18n keys (`llmProviders.select`, `llmEditor.compatibleEndpointOverridden`, `llmEditor.modelType`, `operator.activation.llmEndpoint`) and one reworded (`llmProviders.region.custom`) in all 11 locales.

### CI follow-up: startup crash

The first CI run's E2E jobs could not boot the image: `LlmModule.configure()` is both `@PostConstruct` and `@Inject`, so CDI runs it twice, and the catalog's collision guard (`builders.containsKey(id)`) rejected the providers it had registered itself on the first pass — `IllegalStateException: OpenAI-compatible provider id 'xai' collides with a built-in LLM type`. Unit tests construct the module by hand and call `configure()` once, so they could not see it. The guard now checks a fixed `BUILT_IN_TYPES` set plus duplicates within the catalog, which makes registration idempotent. `LlmModuleTest.configureIsIdempotent` calls `configure()` twice and fails with the original exception when the old guard is restored; the packaged jar was also booted locally against MongoDB and reached `/q/health/ready`.

### Review follow-up: group wizard key reset

CodeRabbit (PR 904) found the group wizard's member and moderator provider selects kept the API key across a provider change, so a key typed for one vendor was submitted with another's configuration — the same gap already closed in the agent wizard. Both handlers now clear `apiKey`; a test switches each slot's provider and asserts the key is empty, and fails with either reset removed. The gap predates this branch; the added providers made it easier to hit.

### Merge of main (2026-09-30)

Main moved by \~270 commits while this was open. Conflicts resolved as follows:

* The agent wizard and the operator form now pass `include={isProvisionableBySetup}` to `ProviderSelect` (new prop), keeping main's rule that setup cannot provision `gemini-vertex`.
* The wizard keeps clearing the API key on *any* provider switch — stricter than main's "carry it between two keyed providers" rule, and it covers main's Jlama-token case too.
* The operator form seeds its stored endpoint only when it keeps the stored provider, since main now falls back from a provider the setup flow no longer offers.
* `DynamicAgentToolsTest`'s new cases verify main's two-argument `setupAgent(request, origin)`; the one-argument form would have made the `never()` check vacuous.
* `operator-activation.test.tsx`'s fallback case (added on main) expected `claude-sonnet-5` while #896 moved the default to `claude-sonnet-5-5` — main's own suite is red on it. It now derives the expected model from `LLM_PROVIDERS[0]`.

### Review follow-up: cross-provider summary model

CodeRabbit (PR 904, outside-diff finding on `LlmTask`): a `conversationSummary` naming another provider but no model inherited the parent's model — with this branch's preset defaults an `xai` parent would send `grok-4.7` to an Anthropic summary. `resolveEffectiveSummaryConfig` now inherits the parent model only when the summary uses the parent's provider (or names none); otherwise the model stays unset and the summary provider's builder applies its own default. The pre-existing `inheritsModelOnly` test had pinned the cross-provider inheritance with a contrived pair and now uses a same-provider parent; `crossProviderDoesNotInheritModel` covers the fix.

That fixed only the summary *config*. CodeRabbit's next review found the second path: on a provider mismatch `resolveInheritedSummaryParameters` stripped credentials and endpoints but let the parent's `modelName` (and `model`, `modelId`, `modelID`) through in the inherited parameters, where `SummarizationService` would still apply it. `PROVIDER_BOUND_PARAMETERS` now includes every key in `ModelParameterKeys.MODEL_KEYS` (`deploymentName` was already there). Tested end to end and at the helper; removing the model keys fails both.

### Design decisions

***

## ⬆️ chore(deps): every stable patch/minor upgrade, backend and UIs (2026-09-28)

**Repo:** EDDI (`chore/deps-minor-upgrades`)

### What changed

Every dependency and tool moves to its newest stable release within the same major (for `0.x` packages, within the same minor). Majors are deliberately left to follow-up branches, one per area.

**Backend (`pom.xml`)**

| Artifact                                                                              | From → To               | Note                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Quarkus platform BOM                                                                  | 3.39.4 → **3.39.5**     | `3.40.0` exists only as `CR1` for the platform BOM; the `io.quarkus:*` `3.40.0` the versions report lists are core artifacts the BOM manages, not a platform release                                                                                                                                                                                                                                                                                                                                                                          |
| `reactor-netty-http`                                                                  | 1.2.8 → **1.2.18**      | Latest patch on the 1.2 line. An earlier revision of this branch took 1.3.7, whose pom matches Quarkus's `reactor-core` 3.8.7, but 1.3.x is built against Netty 4.2 while Quarkus 3.39.5 manages Netty 4.1.138: its default event loop needs `io.netty.channel.MultiThreadIoEventLoopGroup`, so the Azure OpenAI client's first request threw `NoClassDefFoundError` (reproduced; flagged by CodeRabbit). `ReactorNettyNettyCompatibilityTest` now creates that event loop, so the mismatch fails a unit test instead of a production request |
| bcprov-lts8on                                                                         | 2.73.12.1 → **2.73.13** |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| classgraph                                                                            | 4.8.194 → **4.8.196**   |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| swagger-annotations                                                                   | 2.2.54 → **2.2.55**     | `BuildQualityGatesTest` pins it; its constant and Javadoc moved with it                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| swagger-parser                                                                        | 2.1.47 → **2.1.48**     |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| jnats                                                                                 | 2.26.2 → **2.26.3**     |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| surefire / failsafe                                                                   | 3.5.6 → **3.6.0**       | GA (3.6.0-M1 was skipped last time)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| Maven (wrapper + `mise.toml`)                                                         | 3.9.12 → **3.9.16**     | New `distributionSha256Sum`, computed from the zip after checking it against Maven Central's published SHA-512                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Node (`node.version`, `mise.toml`, `ci.yml` `NODE_VERSION` + `NODE_SHA256_LINUX_X64`) | 22.23.2 → **22.23.3**   | The checksum is the linux-x64 `.tar.gz` line of nodejs.org's `SHASUMS256.txt` for v22.23.3                                                                                                                                                                                                                                                                                                                                                                                                                                                    |

**Rule for anything Quarkus manages or integrates with: take the version Quarkus has, not a newer one.** The versions report offered Jackson 2.22.3, but the Quarkus 3.39.5 BOM manages the Jackson family at 2.22.2, so Jackson stays at 2.22.2. Everything else above is either not in the Quarkus BOM (bcprov-lts8on, classgraph, swagger, jnats, the Maven plugins and wrapper) or matches it. `reactor-netty-http` is the counter-example: matching Quarkus's `reactor-core` is not enough when the library also needs a Netty Quarkus does not ship. Mockito and Caffeine are Quarkus-BOM-managed and move with the platform.

**Held back on purpose:** langchain4j `1.20.0` → `1.20.2`, for two reasons.

* **Quarkus:** the Quarkus 3.39.5 platform ships langchain4j **1.19.3**, through `quarkus-langchain4j-bom`. EDDI is already ahead of it at 1.20.0, and by the rule above it moves no further until Quarkus does.
* **OCI GenAI:** `langchain4j-community-oci-genai` was never released past `1.20.0-beta30`, and the pom forbids splitting the two langchain4j lines, since a split surfaces as a runtime `NoSuchMethodError`.

**Manager (`ui/manager`)**: React / React DOM 19.3, `@tanstack/react-query` 5.104, `react-router-dom` 7.18.4, dompurify 3.4.16, tailwind-merge 3.7, typescript-eslint 8.70, `@playwright/test` 1.63, `@testing-library/*`, `eslint-plugin-react-refresh` 0.5.7, and the React and Node type packages.

`eslint-plugin-react-refresh` 0.5.7 now catches a re-exported constant it missed before: `discussion-transcript.tsx` exported `STYLE_THEME` for `group-detail.tsx`, which disables Fast Refresh for that component file. The constant moved to [`discussion-style-theme.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/discussion-style-theme.ts), imported by both.

**Chat (`ui/chat`)**: TypeScript `~5.7` → `~5.9.3` (the Manager's version), katex 0.18.9, vitest 5.0.2, and type packages. `npm update` also raised a few floors to the versions already installed.

Both lockfiles were written by npm inside a Linux `node:22.23.3` container: npm on Windows prunes `@tailwindcss/oxide-wasm32-wasi`'s children and breaks CI's `npm ci`. The four entries are still present. The Manager's `npm update` hit an npm bug with its `overrides` block (`Cannot read properties of null (reading 'edgesOut')`), so its bumps are explicit `npm install`s.

### Verification

Before main was merged in (2026-09-28):

* **`./mvnw clean test`:** 18,043 tests ran.
  * 12 failures and 210 errors, all "Unable to establish loopback connection" or Netty "failed to create a child event loop". That is this machine's environmental baseline for tests that bind loopback sockets.
  * Plus the swagger-annotations constant, since fixed.
* **Guard tests** (`BuildQualityGatesTest`, `ReleaseVersionSourceTest`, `DeploymentManifestsTest`, `ImportStyleTest`, `ComposeStackTest`): pass, apart from the three `DeploymentManifestsTest` PowerShell tests that fail identically on `origin/main` here.
* **Manager:** lint, typecheck, `i18n:check`, and `vitest run --coverage` (426 files, 6,858 tests, no unhandled errors) all pass, as does `vite build`.
* **Chat:** typecheck, vitest (15 files, 278 tests) and build pass.

After merging main (2026-09-30), which brought #831–#853:

* **Guard tests** (`BuildQualityGatesTest`, `ChangelogFragmentTest`, `Documentation*Test`, `ImportStyleTest`, `DeploymentManifestsTest`): 139 of 142 pass; the three failures are the same `DeploymentManifestsTest` PowerShell tests, whose files the merge leaves identical to `origin/main`.
* **`ReactorNettyNettyCompatibilityTest`** (new): passes on 1.2.18 and fails on 1.3.7 with `NoClassDefFoundError: io/netty/channel/MultiThreadIoEventLoopGroup`, the error a plain-Java request reproduced on 1.3.7.
* **Manager and Chat suites:** not rerun on this branch after the merge; CI's UI jobs are the check. (The UI-majors branch reran both after the same merge: Chat 338/338; Manager 7,324/7,327, the three failures being the Windows-only CRLF test and two rag-upload tests that need the toolchain branch's Node 24 fix.)

### Next

The majors, as their own branches: MCP server 2 / testcontainers 2 / vert.x 5; the JSON libraries, bson4jackson 3 and langchain4j 1.20.2; Node 24 / frontend-maven-plugin 2 / JDK patch; and the UI toolchain and runtime majors.

***

## ⬆️ chore(deps): major upgrades — MCP server 2, Testcontainers 2; Vert.x 5 web client held back (2026-09-28)

**Repo:** EDDI (`chore/deps-major-backend-infra`, built on `chore/deps-minor-upgrades`)

### Aligned with what Quarkus ships

All three follow the rule the dependency PRs share. If a library comes from Quarkus or integrates with it, EDDI takes the version the Quarkus platform ships, and nothing newer.

* **MCP server 2.0.1** is exactly what the Quarkus 3.39.5 platform's `quarkus-mcp-server-bom` ships.
* **Testcontainers 2.0.5** is exactly what the 3.39.5 `quarkus-bom` manages. That is why the explicit version pins are gone.
* **The vert.x web client** stays at the BOM's 4.5.34 until Quarkus moves to Vert.x 5.

### What changed

| Artifact                                     | From → To              | Note                                                                                     |
| -------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------- |
| `io.quarkiverse.mcp:quarkus-mcp-server-http` | 1.13.2 → **2.0.1**     | Built against Quarkus 3.33.3.1, so it runs on the 3.39.5 platform. No code change needed |
| Testcontainers                               | 1.21.4 → **2.0.5**     | Module artifacts renamed; versions now come from the Quarkus BOM                         |
| `io.vertx:vertx-web-client`                  | stays **4.5.34** (BOM) | 5.2.0 cannot run on the Vert.x 4 core Quarkus 3.39 ships — see below                     |

**quarkus-mcp-server 2.0.** Its [2.0 breaking changes](https://github.com/quarkiverse/quarkus-mcp-server/blob/main/docs/modules/ROOT/pages/release-notes.adoc) were checked one by one against EDDI and none applies: we use no `quarkus.mcp.server.sse.*` property (our root path is already `quarkus.mcp.server.http.root-path`), send no JSON-RPC batches, never call `McpRequest.protocolVersion()` or override `OutputSchemaGenerator`, and never enabled the MCP metrics that became runtime opt-in. The renumbered `RESOURCE_NOT_FOUND` error code applies only to clients that negotiate the new stateless protocol. 2.0 also fixes an exact-match hole in the extension's DNS-rebinding `Origin` check and makes `server/discover` run `McpToolFilter` with an active request context.

**Testcontainers 2.** Module artifacts carry a `testcontainers-` prefix (`testcontainers-junit-jupiter`, `testcontainers-mongodb`, `testcontainers-postgresql`), and `MongoDBContainer` / `PostgreSQLContainer` moved to `org.testcontainers.mongodb` / `org.testcontainers.postgresql` — the new `PostgreSQLContainer` is not generic. The explicit `1.21.4` pins are gone: the Quarkus BOM already manages the whole 2.0.5 family, and the pins had been overriding it, so the tests ran a different Testcontainers than Quarkus Dev Services. No test used the removed JUnit 4 integration.

**Vert.x 5 web client — not taken.** `vertx-web-client` is managed by the Quarkus BOM at 4.5.34 alongside `vertx-core` 4.5.34. Pinning 5.2.0 leaves a mixed classpath (`dependency:tree`: `vertx-web-client:5.2.0` over `vertx-web-common:4.5.34` and `vertx-core:4.5.34`), fails to compile (`HttpClientWrapper`'s callback-style `send`/`sendBuffer` are gone in 5), and could not run even after a Future-based rewrite: the 5.2.0 jar references `io.vertx.core.internal.*` (`ContextInternal`, `VertxInternal`, `HttpClientInternal`, …), a package with zero classes in `vertx-core` 4.5.34. It moves when Quarkus moves to Vert.x 5.

**Files:** [`pom.xml`](https://github.com/labsai/EDDI/tree/main/pom.xml), [`MongoTestBase.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/datastore/mongo/MongoTestBase.java), [`MongoConversationMemoryStoreTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/datastore/mongo/MongoConversationMemoryStoreTest.java), [`PostgresTestBase.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/datastore/postgres/PostgresTestBase.java), [`ContainerBaseIT.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java), [`PostgresAgentUseCaseIT.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java)

### Verification

* `./mvnw compile` / `test-compile` green (Checkstyle and `formatter:validate` included).
* MCP unit tests (`Mcp*Test`): 1377 run, 0 failures.
* Every container-backed store test (the `MongoTestBase` / `PostgresTestBase` subclasses, the tenant-quota parity tests, `MongoConversationMemoryStoreTest`) against real Docker containers: 564 run, 0 failures.
* Container ITs (`verify -DskipITs=false`): `AgentUseCaseIT`, `A2aEndpointIT` (the `ContainerBaseIT` image, which boots the extension in real Quarkus), `PostgresAgentUseCaseIT` and `NatsConversationCoordinatorIT` all green. `McpEndpointIT` against that same image: 5 of 5. `McpCallsCrudIT` / `PostgresMcpCallsCrudIT` could not run on the Windows workstation — a native `mongod` already holds the fixed Dev Services port 27017, and in-process Quarkus boot hit the known "failed to create a child event loop" — so CI is their gate.
* Full unit suite: 23,332 run, 8 failures / 210 errors, every one the workstation's known environmental kind ("Unable to establish loopback connection", "Failure opening selector", Netty child event loop), in classes none of these upgrades touch.

***

## ⬆️ chore(deps): json-path 3; langchain4j and three Jackson-3 libraries held to what Quarkus ships (2026-09-28)

**Repo:** EDDI (`chore/deps-major-backend-libs`, on top of `chore/deps-minor-upgrades`)

### The rule

**If a library comes from Quarkus or integrates with it, EDDI takes the version the Quarkus platform ships, and nothing newer.** A newer version waits until Quarkus has it; otherwise there has to be a concrete reason. Each item below either sits outside Quarkus entirely or is held on that rule.

### What changed

| Artifact                        | From → To          | Note                                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `com.jayway.jsonpath:json-path` | 2.10.0 → **3.0.0** | Not in the Quarkus BOM. 3.0 raises the baseline to Java 17 and adds optional Jackson 3 providers, which load only when selected. The public `JsonPath` class is identical under `javap`, and the default provider is still json-smart 2.6.0 (runtime scope, unchanged). EDDI's one caller, `ContextMatcher`, needed no change: `ContextMatcherTest` passes 22/22 and `RuleConfigValidationTest` 14/14 |

### Held

**langchain4j stays at 1.20.0.** The Quarkus 3.39.5 platform ships langchain4j **1.19.3** through `quarkus-langchain4j-bom`. EDDI is already ahead of that at 1.20.0, so it goes no further until Quarkus moves.

An earlier commit on this branch took 1.20.2, pinning `langchain4j-community-oci-genai` at `1.20.0-beta30` because that module has no 1.20.2 release. That commit is reverted, together with its pom-comment exception and its `BuildQualityGatesTest` allowance. The investigation still holds for when Quarkus moves:

* Every `.class` in langchain4j-core 1.20.2 is byte-identical to 1.20.0.
* Maven mediates the OCI module onto the newer core.

**Three libraries need Jackson 3.** Each uses `tools.jackson.*` in its public API, which the `ban-jackson3` enforcer rule forbids. The Quarkus 3.39.5 BOM manages Jackson 2 (2.22.2), so they stay on their newest Jackson-2 versions until Quarkus moves to Jackson 3:

| Artifact                                                       | Stays at | Blocked by (Maven Central pom and `javap`)                                                                                                                             |
| -------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `com.networknt:json-schema-validator`                          | 1.5.9    | 3.0.7 depends on `tools.jackson.core:jackson-databind` (compile scope), and `Schema.validate(ExecutionContext, tools.jackson.databind.JsonNode)` takes Jackson 3 nodes |
| `com.github.victools:jsonschema-generator` + `-module-jackson` | 4.38.0   | In 5.0.0, `SchemaGenerator.generateSchema(...)` returns `tools.jackson.databind.node.ObjectNode`                                                                       |
| `de.undercouch:bson4jackson`                                   | 2.18.0   | In 3.x, `BsonFactory extends tools.jackson.core.json.JsonFactory`                                                                                                      |

***

## ⬆️ chore(deps): Node 24 LTS, frontend-maven-plugin 2 and Temurin 25.0.4.1 (2026-09-28)

**Repo:** EDDI (`chore/deps-major-toolchain`, on top of `chore/deps-minor-upgrades`)

### What changed

The build-toolchain majors deferred from the patch/minor branch, one commit each, plus one Manager test fix that Node 24 required.

| Tool                  | From → To                                                 | Where                                                                                                                                                                                                                                                       |
| --------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node                  | 22.23.3 → **24.21.0** (LTS "Krypton")                     | `pom.xml` `node.version`, `mise.toml`, `ci.yml`'s `NODE_VERSION` and `NODE_SHA256_LINUX_X64` (linux-x64 `.tar.gz` line of nodejs.org's `SHASUMS256.txt` for v24.21.0), and the docs that name the vendored Node (`AGENTS.md`, `README.md`, both UI READMEs) |
| frontend-maven-plugin | 1.15.1 → **2.0.2**                                        | `pom.xml`                                                                                                                                                                                                                                                   |
| JDK (mise)            | `temurin-25.0.3+9.0.LTS` → **`temurin-25.0.4+101.0.LTS`** | `mise.toml`                                                                                                                                                                                                                                                 |

**Node 24.** Node 22 leaves LTS on 2027-04-30; Node 24 is supported until 2028-04-30. CI used to take `node-version: 22`, the newest 22.x, while the pom pinned an exact version. Main has since moved every `setup-node` step onto one `env.NODE_VERSION` with a checksum the Build Image job verifies the archive against; this branch sets that pin to `24.21.0`, so the UI jobs, the Build Image job's Maven build and `mise` run the same Node. The UI packages' own floor is unchanged at 22.18, so a contributor still on Node 22 can run `npm run dev`. No `package.json` changed: neither UI declares `engines`, and `@types/node` belongs to the UI-majors branch.

**frontend-maven-plugin 2.** 2.0.0 is a dependency-and-baseline release: it needs Java 17 and Maven 3.6, and its `install-node-and-npm` and `npm` mojos did not change between 1.15.1 and 2.0.2. The executions stay as they were. It also fixes archive extraction on Windows under Java 25. One visible change: 2.0.2 logs a child process's stderr at `ERROR`, so npm and Vite warnings now appear as `[ERROR]` lines in a successful build. The pom comment warns about this.

**JDK.** Adoptium's newest GA for 25 is `jdk-25.0.4.1+1`. mise names it `25.0.4+101.0.LTS` (its semver form folds the fourth component into the build number, the same for every `.1+1` Temurin release in its catalogue). The identifier was read from mise's own metadata (`mise-java.jdx.dev/jvm/ga/{linux,windows,macosx}/…json`), which lists it on every platform. mise itself was not installed here, so the pin was not exercised by a `mise install`.

### Verification

* `./mvnw compile` after each commit.
* `./mvnw package -DskipTests` twice: with Node 24 on plugin 1.15.1, then on plugin 2.0.2. Both installed `v24.21.0` into `ui/node`, ran `npm ci` and `npm run build` for both UIs, and ended `BUILD SUCCESS`. Under Node 24's npm 11.19, `npm ci` warns that `esbuild` and `msw` install scripts are "not yet covered by allowScripts". They still run: the `msw` postinstall rewrote `mockServiceWorker.js`, which was restored afterwards.
* **Chat, in a Linux `node:24.21.0` container:** typecheck and vitest pass (15 files, 278 tests).
* **Manager, in a Linux `node:24.21.0` container:** lint, typecheck and `i18n:check` pass. In the full vitest run, 422 of 423 files passed. Three more files never started because a vitest worker timed out, the container flakiness seen before; all three pass when run alone. The one failing file is Node 24 itself, fixed in this branch (below).
* **Guard tests:** `ImportStyleTest`, `BuildQualityGatesTest`, `ChangelogRotationTest`, `ChangelogFragmentTest`, `ReleaseVersionSourceTest`, `DocumentationLinksTest`, `DeploymentManifestsTest`, `ComposeStackTest` and `JlamaRuntimeFlagsTest` all pass. The exception is the three `DeploymentManifestsTest` PowerShell-generator tests, which fail on this machine regardless of the change.

### The one test Node 24 broke

`resource-detail-rag-upload-source.test.tsx` failed both of its upload tests on 24.21.0 and passed on 22.23.3. The cause: Node 24's fetch (undici 7) brand-checks `Blob` and `File`, where Node 22's accepted anything shaped like one, and jsdom replaces the global `File`. That has two effects:

* The dropped jsdom `File` reaches the MSW handler with an empty body.
* `request.formData()` then fails undici's own assertion on the `File` it builds from the global.

The product code is unaffected, because a browser has only one `File`. The handlers now read the first part's field name and content type from the raw multipart body, which survive on every Node, and tell the PDF refusal apart by its content type (commit `d09d1b120a`). Mutation-checked: an impossible content type in that match fails the test.

### Existing checkouts: a Node install per version

The first integration run of all the dependency branches together failed in `npm ci` with `Class extends value undefined is not a constructor or null`. It only fails on a checkout that already had a `ui/node` from Node 22, which is every developer's; fresh clones and CI are fine. frontend-maven-plugin replaces the node binary over an existing install but unpacks the new npm on top of the old one without clearing it, and the mixed npm tree crashes on start. `ui/node` is gitignored, so `./mvnw clean` does not remove it either.

`installDirectory` in [`pom.xml`](https://github.com/labsai/EDDI/tree/main/pom.xml) is now `ui/node/${node.version}`: every Node version gets its own directory, so a bump can never mix installs, and the old one is simply never used again. Verified by `./mvnw package` on the checkout that had failed: it installed v24.21.0 into `ui/node/v24.21.0/node`, left the old files alone, and built both UIs.

### Next

The UI toolchain and runtime majors (including `@types/node` 24) stay on their own branch.

***

## ⬆️ chore(deps): major upgrades for the Manager and Chat UIs (2026-09-28)

**Repo:** EDDI (`chore/deps-major-ui`, on top of `chore/deps-minor-upgrades`)

### What changed

Every stable major (npm `latest`) for the two UIs, except where a required peer has no compatible release yet. Those stay on their current version, and the peer that blocks each one is named below.

**Manager (`ui/manager`)**

| Package                     | From → To                 | Note                                                                                                                                                                    |
| --------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| vite                        | 6 → **8.3**               | Vite 8 bundles with Rolldown. `build.rollupOptions` is now `build.rolldownOptions`                                                                                      |
| @vitejs/plugin-react        | 4 → **6.1**               | Needs Vite 8. No options are used, so there is nothing to migrate                                                                                                       |
| vitest, @vitest/coverage-v8 | 4 → **5.0**               | Coverage thresholds unchanged and still met. `vitest.config.ts` now imports `./vite.config.ts` with its extension, which Vite's native config loader requires           |
| jsdom                       | 26 → **30.0** (`~30.0.1`) | Held below 30.1, see below                                                                                                                                              |
| @testing-library/jest-dom   | 6 → **7.0**               | The `/vitest` entry point is unchanged                                                                                                                                  |
| @types/node                 | 25 → **24.19**            | Follows the runtime, not the newest Node: the repo is moving to Node 24 LTS on another branch                                                                           |
| eslint, @eslint/js          | 9 → **10**                | See below                                                                                                                                                               |
| eslint-plugin-react-hooks   | 5 → **7.1**               | See below                                                                                                                                                               |
| globals                     | 15 → **17**               |                                                                                                                                                                         |
| i18next                     | 24 → **26.4**             | Nothing removed in 25 or 26 is used (legacy `interpolation.format`, `initImmediate`, `showSupportNotice`)                                                               |
| react-i18next               | 15 → **17.0**             | 17 changes only the keys `<Trans>` generates, and the Manager has no `<Trans>`                                                                                          |
| react-markdown              | 9 → **10.1**              | 10 drops the `className` prop, which the Manager does not pass                                                                                                          |
| lucide-react                | 0.577 → **1.48**          | See below                                                                                                                                                               |
| diff                        | 8 → **9.0**               | `diffLines` / `diffWordsWithSpace` are unchanged                                                                                                                        |
| monaco-editor               | 0.56 → **0.57**           | The `dompurify` override still applies to it                                                                                                                            |
| **typescript**              | **stays 5.9**             | **Blocked by typescript-eslint**: 8.71.0, the latest, declares `typescript: ">=4.8.4 <6.1.0"` for itself and `@typescript-eslint/parser`, and the Manager lints with it |

**Chat (`ui/chat`)**: vite 6 → **8.3**, @vitejs/plugin-react 4 → **6.1**, jsdom 26 → **30.0** (`~30.0.1`), @testing-library/jest-dom 6 → **7.0**, @types/node 22 → **24.19**. The configs now use `import.meta.dirname` instead of `__dirname`, which Vite's native config loader does not support.

While this PR was open, `main` gave Chat its own ESLint setup. Merging it in, that lint stack takes the same majors as the Manager's:

* eslint and @eslint/js 9 → **10**;
* eslint-plugin-react-hooks 5 → **7**, with the two classic rules named explicitly instead of spreading `recommended`, exactly as in the Manager;
* globals 15 → **17**;
* typescript-eslint → **8.71**.

It also means **Chat stays on TypeScript 5.9**. This branch had moved Chat to 7.0 only because it had no ESLint then. typescript-eslint's `typescript: ">=4.8.4 <6.1.0"` now blocks Chat exactly as it blocks the Manager.

**jsdom is held at 30.0.x.** jsdom 30.1 moved `Blob`'s implementation object from a symbol-keyed property into a private class field. Vitest 5.0.2's jsdom environment still finds that object by scanning a Blob's own symbols. As a result, every `File`, `Blob` or `FormData` request body throws `Cannot read properties of undefined (reading '_bytes')`. On 30.1.1 that failed 23 Manager tests, covering attachments, backup import and RAG upload ([vitest-dev/vitest#11336](https://github.com/vitest-dev/vitest/issues/11336), fix pending in [#11370](https://github.com/vitest-dev/vitest/pull/11370)). Lift the `~` pin once a Vitest release carries that fix.

**ESLint 10.** Two rules are new in `eslint:recommended`, and the code is fixed to pass them:

* `no-useless-assignment` found 13 initialisers that every branch overwrites (for example `let nextIndex = null` ahead of an exhaustive `switch`). They are now bare declarations.
* `preserve-caught-error` found one error that dropped its cause. The operator's "rollback ALSO failed" error now carries the rollback failure as `cause`, and `tsconfig.app.json` / `tsconfig.design-sync.json` gain the `ES2022.Error` lib for the constructor's options argument.

**eslint-plugin-react-hooks 7 puts the React Compiler rules in `recommended`.** Spreading that preset, as the config did, would have turned on 119 findings:

| Rule                          | Findings |
| ----------------------------- | -------- |
| `set-state-in-effect`         | 66       |
| `refs`                        | 36       |
| `immutability`                | 7        |
| `purity`                      | 6        |
| `static-components`           | 2        |
| `preserve-manual-memoization` | 2        |

[`eslint.config.js`](https://github.com/labsai/EDDI/tree/main/ui/manager/eslint.config.js) now names the two rules the v5 preset enabled, so the gate checks exactly what it checked before. Adopting the compiler rules is a change of its own.

**lucide-react 1.0 removed every brand icon.** The Manager used one, `Github`, on the Updates page. It is now a local `GithubIcon` in [`update-check-card.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/shared/update-check-card.tsx) with the same outline path, so it renders unchanged. No other icon name changed for the Manager: `tsc -b` passes against 1.48.

**One test bound was raised.** `app-routing.test.tsx` waited 5 s for each lazy page chunk. It is the first test to import `/manage/agents`, so that wait includes transforming the page's whole import graph. On an idle machine this takes about 1 s. Under the full suite's parallel load it measured 5.4 s and failed twice in four full runs on this branch, although the chunk resolved. The bound is now 15 s. The test timeout is 30 s.

The four `oxide-wasm32-wasi` lockfile entries are still present. Both lockfiles were written by npm inside a Linux `node:22.23.3` container, because npm on Windows prunes those entries and breaks CI's `npm ci`.

### Verification

All checks below ran on Windows with the project's Node 22.23.3, from a host `npm ci` against the final lockfiles.

* **Manager:**
  * `npm run lint`, `npm run typecheck` and `npm run i18n:check` (4,507 keys, 11 locales) pass.
  * `npx vitest run --coverage` passes: 426 files and 6,858 tests, with no unhandled errors. Coverage is 82.35 % statements, 77.17 % branches, 76.92 % functions and 83.99 % lines, against thresholds of 81 / 75 / 70 / 83.
  * `npm run build` passes and still emits the three shells, with all 473 font files kept as files.
  * Playwright `test:e2e` (MSW): 234 of 234 passed. The first run had one failure: the app shell missed its 15 s wait while the freshly started Vite 8 dev server was still cold.
* **Chat:** typecheck, vitest (15 files, 278 tests) and build pass. After merging `main`'s ESLint setup in: lint (ESLint 10), typecheck on TypeScript 5.9.3, vitest (20 files, 338 tests) and build pass.
* **`./mvnw package -DskipTests`:** `BUILD SUCCESS`. It ran `npm ci` and the build for both UIs, and left no change in either lockfile.

### Next

* Remove jsdom's `~30.0.1` pin once Vitest ships the fix in vitest-dev/vitest#11370.
* Move both UIs to TypeScript 7 once typescript-eslint supports it.
* Adopt the React Compiler lint rules, one at a time.

***

## 📝 docs(agents): the AI instructions no longer teach APIs that do not exist, and cost a third less to load (2026-09-28)

**Repo:** EDDI (`docs/ai-instructions-review`)

Every AI session in this repository loads the root `AGENTS.md` in full — through `CLAUDE.md` and the Cursor rule — and a Manager session is additionally told to read `ui/manager/HANDOFF.md` before starting. A review of all the instruction files against the code found three kinds of problem: instructions that are wrong enough that following them produces code that does not compile or does not work, stale counts and paths, and a context cost (84 KB for the root file, 178 KB for the handoff log) that most sessions pay for nothing.

### Wrong instructions, now corrected (root `AGENTS.md`)

* **§4.1 rule 4 said "no manual module registration".** Every lifecycle task is also registered by a `@Startup` bootstrap module (`ApiCallsModule`, `RagModule`, …) in the `@LifecycleExtensions` map; without one a workflow naming the step cannot be deployed. The §4.3 checklist and the new-feature file list now include the module.
* **§4.5's "Complete Task Example" did not compile** — `MyFeatureTask` was both the task class and the element type `getActions()` was called on. Replaced with a table pointing at the real API-call extension (task, module, configuration, store, REST pair, unit test), which is the smallest complete implementation and cannot drift from the code.
* **§4.4's PrePostUtils snippet called `executePostResponse`**, which does not exist (the method is `runPostResponse(memory, postResponse, templateData, httpCode, validationError)`), and discarded the map `executePreRequestPropertyInstructions` returns, so later templating would run on stale data.
* **§4.2** named `memory.getCurrentData(...)`, `addConversationOutput(...)` and `AgentOrchestrator.buildToolList` — none exist — and a `builtInTools` field (the fields are `enableBuiltInTools` / `builtInToolsWhitelist`, and a new tool joins `BuiltinToolsProvider`'s catalog). Tool assembly is now described as `buildToolSetup` → `ToolSourceProvider.contribute`.
* **§4.2 said `setPublic(true)` makes data "output-visible".** `isPublic()` is copied into snapshots and filters nothing; what reaches a client is decided by key prefix in `ConversationMemoryUtilities.convertSimpleConversationMemory` (`returnDetailed=true` returns everything). The instruction now says so, since "set it false to hide it" is the wrong lesson.
* **§4.1 rule 5 named a `ConversationCoordinator` class** — it is `IConversationCoordinator`, in-memory or NATS.
* **§4.3 / §4.6 assumed MongoDB and `@QuarkusTest`.** Stores extend `AbstractResourceStore` over `IResourceStorageFactory` (MongoDB and PostgreSQL; the `mongo/` package names are historical) and inherit `@ConfigurationUpdate`; task unit tests are plain JUnit 5 + Mockito. "Use Java records" is gone too: every configuration class under `configs/` is a POJO with getters, and `LlmConfiguration` is the one record.
* **§2 rule 9 told sessions to `git stash` / `git stash pop`.** The stash stack is shared by every worktree of a clone, so a bare pop can apply another session's work. It now says to park work as a `wip:` commit, or use a tagged stash applied by SHA.

### Smaller: the root file is 56 KB instead of 84 KB

* **§5 Agent Config Authoring (22 KB) moved to** [**`docs/agent-config-authoring.md`**](/agent-configuration/agent-config-authoring.md), a published page (added to `SUMMARY.md`) that is useful to human config authors too. §5 keeps a trigger ("writing agent JSON? read this first") and the five mistakes the page exists to prevent. Section numbers §1–§6 are unchanged because Java comments and tests cite them; every citation of the old §5.x subsections — in `ReservedActionLint`, `WorkspaceAccessIndexMigration`, three tests and four published pages (`conversation-memory`, `properties`, `httpcalls`, `open-webui-integration`) — now names the new page, and `DocumentationAccuracyTest` / `DocumentedRestPathsTest` grade the page instead of `AGENTS.md`.
* `docs/langchain.md` taught the same nonexistent `buildToolList` diagram and now matches §4.2.
* \#858 corrected two paragraphs of the old §5 while this was open (rule groups fire only their first matching rule by default; runtime data is never template text, and runtime templates use a restricted engine). Both are carried into the new page, §5's pitfall list and — the Java half — §4.4, so the move does not revert them.
* **§3's "Completed" table** (phase history) became a table of what already exists, each row pointing at its doc — the part an assistant actually needs before building something parallel.
* Generic advice models already follow (null checks, log levels) collapsed to the EDDI-specific rules; the Docker section condensed; table padding removed; `architecture.md` (50 KB) is now "read the section for your area", not required reading in full.
* Also: stale test count (14,000 → \~18,000), the security-scan list (ZAP was removed from CI, and push/PR CodeQL runs in `ci.yml`, not `codeql.yml`), a roadmap row still listing the Manager approvals UI as to-do (it ships at `/manage/approvals`), the docs host (docs.labs.ai, not eddi.labs.ai), a branch-naming rule (rename a tool-generated `claude/*` branch before the first commit), a note that `-Dtest` runs skip the repo-wide guards, and the no-attribution rule extended to PR comments and review replies.

### Manager and Chat UI instructions

* **`ui/manager/AGENTS.md`**: `HANDOFF.md` is no longer required reading (it is 180 KB) or required writing — Manager work goes into a root changelog fragment like every other change, since every PR appending to one shared file is the conflict the fragments were built to end. `HANDOFF.md` carries a banner saying so. The quality-gate list is now what CI runs (`audit:prod`, `lint`, `i18n:check`, `typecheck`, `test`), the Stryker text no longer tells you to expect a CI job that does not exist, the stale `EDDI-integration-tests` repo row and the eight-directory file tree are replaced, `renderPage` is described with its real signature, the i18n and RTL rules are stated once instead of three times, and commits are scoped (`fix(manager):`) as they are in practice.
* **`ui/manager/CLAUDE.md` and the three skills**: the hard-coded counts (11 primitives, 14 shared, 25 components, per-page tallies) had all drifted and are gone; `ResizeHandle`, `ChipInput`, `StepDots` and the connection-reference components, the Button `warning` variant and the `warning` / `popover` / `ring` tokens are documented; `eddi-data` no longer describes a pre-commit hook that was removed.
* **`ui/chat/AGENTS.md`**: the backend emits **nine** SSE events, not eight — `tool_call` has been sent since 2026-08-14 and this widget does not handle it yet (a code follow-up, not done here). Known `error` events carry a `code`. The query-parameter list is complete, the approvals route is the Manager's `/manage/approvals`, the personal `c:\dev\git` path is gone, and the CI gates (`typecheck`, `lint`, `test`) are named.

### Tooling

* `.cursor/rules/project-context.mdc` points at the nested `ui/*/AGENTS.md` files.
* `.claude/skills/ship-pr/SKILL.md` lost a citation of another project's pull request and two "this changed" remnants.

### Follow-ups

* Handle the `tool_call` SSE event in `ui/chat` (`SSEEventType` in `src/types.ts` and the event `switch` in `src/components/ChatWidget.tsx`).

***

## ✨ feat(chat-ui): show "Using {tool}…" from the live `tool_call` SSE event (2026-09-28)

**Repo:** EDDI (`feat/chat-ui-tool-call-status`)

### What changed and why

The backend has sent a `tool_call` event (`{"tool":"<name>"}`) right before each LLM tool runs since 2026-08-14 (`RestAgentEngineStreaming.onToolCall`). The Manager turned it into a "Using {tool}…" status line, but the standalone Chat UI dropped it, so a slow tool looked like the agent thinking for no reason. The widget now shows the same wording in its existing status indicator.

* **`sse-events.ts`** — `parseToolCallName()` reads the name, returns `null` for anything unusable (so a malformed event never renders "Using undefined…"), and caps it at 48 characters so an agent designer's long tool identifier cannot stretch the bubble.
* **`chat-store.tsx`** — new transient `activeTool` plus `SET_ACTIVE_TOOL`. `FINISH_STREAMING` and `CLEAR_MESSAGES` clear it, the same funnel that already covers escalation across done, error, stop-generating and a HITL pause. An escalation also clears it, because the model that was calling the tool has just been abandoned.
* **`ChatWidget.tsx`** — handles `tool_call` and clears the tool on every token and on `task_failed`, and at the start of each new turn.
* **`Indicators.tsx`** — `ThinkingIndicator` takes a `tool` prop ("Using calculator…", with lucide's `Wrench` in place of `Brain`, the icon set #875 moved the widget to). It is a prop on the same component rather than a new one, for the reason `escalating` is: React keeps the element and does not replay the entrance animation when the copy changes mid-wait.
* **`ui/chat/AGENTS.md`** — the backend-contract note that `tool_call` "is not handled by this widget yet" is replaced by its payload and how the indicator is cleared.

### Announced from outside the busy transcript (review follow-up)

After merging `main`, the transcript became a `role="log"` with `aria-busy={isProcessing}`, so a streamed reply is read once rather than token by token. The indicator was a `role="status"` region *inside* that log, and a live region inside a busy one may not be announced at all. Every indicator state exists only mid-turn, so "Using calculator…" (and "Thinking…") could never reach a screen reader. The visual indicator stays where it is but is now `aria-hidden`. Its text, from the new `indicatorStatusText()`, is announced by an always-mounted `.chat-sr-only` status region placed after the transcript, the same pattern `ChatInput` uses. CodeRabbit suggested moving the whole indicator out of the transcript instead; that would have pinned it above the composer and changed the layout for sighted users to fix a problem only assistive technology has. `ui/chat/AGENTS.md` now also lists `task_failed` among the events that clear the tool status, which the widget already did.

### Design decisions

* **Resumed output is the completion signal.** No event says a tool has finished (the per-call result reaches the client only in the turn-end `toolTrace`), so the next token clears the status. The Manager (`liveToolsSettled`) settles the same way.
* **Not gated on `tokenCount`**, unlike `task_start`. A model can write a sentence and then call a tool; the silence while that tool runs is exactly the wait worth explaining, and the next token takes the status away again.
* **Precedence: tool, then escalation, then thinking.** A tool called after an escalation is the stronger model at work, so it shows the tool. The tool name is shown as sent: it is the identifier the agent designer chose, unlike the cascade's confidence and model name, which stay admin-side.

### Tests

Parser cases in `sse-events.test.ts`, the event added to the wire-format sweep in `chat-api.test.ts` (the `test-utils/sse.ts` harness), and reducer cases in `chat-store.test.tsx`. `ChatWidget.test.tsx` has seven stream-driven cases: name shown, newest tool wins, cleared by a token, cleared by `done`, shown after text has already streamed, malformed payload ignored, and a later escalation taking over. The shared open-stream helpers moved to module scope so the cascade and tool-call suites can both use them. Mutation-checked: removing the `tool_call` dispatch fails three widget tests, and removing the per-token clear fails one. `npm run typecheck` and `npm test` (295 tests) pass in `ui/chat`.

**Files:** [`sse-events.ts`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/api/sse-events.ts), [`chat-store.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/store/chat-store.tsx), [`ChatWidget.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/ChatWidget.tsx), [`Indicators.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/Indicators.tsx), [`types.ts`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/types.ts), [`ui/chat/AGENTS.md`](https://github.com/labsai/EDDI/tree/main/ui/chat/AGENTS.md)

***

## 🔒 fix(backup): agent sync keeps the target's secrets, carries structural changes, and stops overwriting local edits (2026-09-28)

**Repo:** EDDI (`fix/agent-sync-hardening`)

### What changed and why

Agent Sync was driven end to end between two packaged instances — staging and production on separate databases and vault keys — through about 25 scenarios over the REST API and the production Manager: iterate on staging several times, promote once, roll back, add and remove steps and whole workflows, hotfix production, sync in batches and concurrently. The basic loop held up. What did not, all confirmed against every open branch before fixing:

**The source's plaintext secrets reached the target.** `RemoteApiResourceSource` read each document with a plain store `GET`, which answers with raw configuration. A first promotion goes through the export archive and is scrubbed; every later sync was not — staging's `Authorization: Bearer <token>` was shown in the preview (so in the production operator's browser) and written into production. Every document a live sync reads now goes through `SecretScrubber` first ([`RemoteApiResourceSource`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RemoteApiResourceSource.java)); `RestImportService` injects the container's scrubber and falls back to a default one, so a missed wiring cannot switch scrubbing off.

**Production's own vault references were overwritten.** The scrubber leaves `${vault:…}` legible on purpose, so it travelled and replaced the target's pointer: staging's `${vault:openai-key}` over production's `${vault:prod-openai-key}`, breaking every LLM call. A value holding a vault reference is now bound to the target like a placeholder ([`ScrubbedSecrets`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/ScrubbedSecrets.java)): the target's own value at the same place wins; where it has none, the reference travels as the hint of which entry to create.

**Adding a workflow wrote an undeployable agent and answered 201.** The new workflow was stored as the source had it, naming resource ids only the source has, and none of its resources were created. Removing it again on the source was a `200 nothing to write`, so no sync could repair it. `UpgradeExecutor` now creates the workflow's resources first and writes the workflow naming them; a workflow the source removed comes off the agent.

**An added step could not be promoted at all.** The preview said `CREATE`, the execute answered `207` and refused the whole workflow update, telling the operator to hand-edit production — and once a sync had removed a step, it could never add it back. A resource for a step the source added is now created when the workflow adoption that places it is certain; that is decided *before* anything is written (`adoptionBlocker`), so a refused adoption no longer leaves an unreferenced copy.

**The preview disagreed with the execute.** Agents and workflows were compared with each side's own ids, so every agent showed two phantom changes forever; removals were invisible; and agent-level settings (HITL, capabilities, memory policy) previewed as an agent `UPDATE` that was never written. [`StructuralMatcher`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/StructuralMatcher.java) now compares the source rewritten onto the target's references (and identity), emits `REMOVE` rows for steps and workflows the source no longer has, and the executor writes the agent's settings — keeping the target's instance-bound `identity`.

**A production hotfix was overwritten silently.** `DocumentDescriptor.syncedVersion` now records the version a sync or import wrote; a target version past it was made locally, and a resource changed on both sides is a `CONFLICT`, written only when named in `selectedResources` (the Manager's **Overwrite local change**). The agent's own settings and a workflow's steps are guarded the same way, each compared with its synced version rewritten onto today's references so that Manager edits cascading new versions do not count; when they are left alone, the rest of the sync still lands. A dictionary only a parser document names is created or updated only when that document will be written (for a parser the target's step names but no longer has, only once the store confirms it is gone), and a batch entry names the agent it was written into.

**Merged with `main` (2026-09-30).** `main` now counts workflows and extensions as updated only once the agent that loads them is written; a dictionary that only a parser document names follows the same rule, delivered with the parser's step. A synced agent write is never compatible (`updateAgent(..., false)`), and the agent's store-owned `compatibilityGeneration` is carried from the target like its identity, so a save made here is no agent CONFLICT. The parser-document rows moved with the §5 reference from `AGENTS.md` to [`agent-config-authoring.md`](/agent-configuration/agent-config-authoring.md).

**Smaller ones.** A sync without `targetAgentId` now finds the agent an earlier promotion made (by `originId`) instead of creating a copy on every call — `409` when several exist, `createNew=true` to force one. `selectedResources` present but empty is a `400` instead of "everything". Duplicate snippet names travel as the one the runtime renders, with a preview `warning` — export, sync and import agree on which. A nonexistent target is a `404` with a body, not an empty `500`; every sync error answers `{"error": …}`; a failed remote read is logged once, at WARN. Snippets an import creates get a named descriptor.

**Manager.** The sync page matches local agents by `originId` before name, forces a copy only when the operator picks *Create new*, adopts the target a preview resolved, shows `REMOVE`/`CONFLICT` rows with their diffs and the preview's warnings, lets a conflict be overwritten per row, and says per agent what was written and that it is not live until deployed. The import dialog leaves `CONFLICT` rows unticked, pre-selects the local copy promoted from the chosen agent as soon as it is picked, and creates a new agent only when *Create new agent* is chosen explicitly — so leaving the default never duplicates and choosing a copy is always honoured. i18n in all 11 locales.

Three commits that followed the merge of #829 on `fix/agent-sync-promotion` — parser documents travelling with the agent — were never pushed; they are the base of this branch (entry below).

### How it is guarded

`RemoteApiResourceSourceScrubbingTest` (real scrubber and serializer), `StructuralMatcherSyncTest`, `UpgradeExecutorSyncTest`, vault-reference cases in `ScrubbedSecretsTest`, and five sync-page and one import-dialog vitest — each mutation-checked: the fix reverted, the test confirmed red. The live two-instance run was repeated on the fixed build.

**Files:** [`RemoteApiResourceSource.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RemoteApiResourceSource.java), [`ScrubbedSecrets.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/ScrubbedSecrets.java), [`StructuralMatcher.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/StructuralMatcher.java), [`UpgradeExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/UpgradeExecutor.java), [`RestImportService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RestImportService.java), [`RestExportService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RestExportService.java), [`ImportPreview.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/model/ImportPreview.java), [`DocumentDescriptor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/descriptors/model/DocumentDescriptor.java), [`sync-page.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/sync-page.tsx), [`preview-step.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/agents/import-steps/preview-step.tsx), [`agent-sync-guide.md`](/conversations-and-orchestration/agent-sync-guide.md), [`agent-sync-architecture.md`](/reference/agent-sync-architecture.md)

***

## 🧩 feat(backup): parser documents, and the dictionaries they name, travel with the agent (2026-09-28)

**Repo:** EDDI (`fix/agent-sync-hardening`)

### What changed and why

`ai.labs.parser` was not in the backup registry, so neither an export nor a live sync carried a parser document. Every agent the setup wizard creates has a parser step whose `config.uri` names one, so every promoted agent arrived naming a parser that existed only on the source — and a dictionary that only that document named did not travel at all. Parser documents are now exported, imported, previewed and synced like every other extension ([`WorkflowExtensions`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/WorkflowExtensions.java), [`AbstractBackupService`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/AbstractBackupService.java)), and the dictionaries a parser document names are found in the document, carried, and the document repointed at the target's copies.

What that took, beyond registering the type:

* **References inside a document.** Workflow-level matching repoints a *step*; it never looked inside a document. A parser document holds its dictionaries' ids itself, and those differ between instances by construction, so compared as written it read as changed on every sync and was then written with the source's ids. The dictionaries a parser document names are now resources of their own in the match: [`WorkflowExtensions.scanDocument`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/WorkflowExtensions.java) keys each one under the document, both sources and the target derive the same keys, and the preview, the create and the update treat them like any other resource. [`NestedReferences`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/NestedReferences.java) then swaps each reference in the parser for its matched counterpart, by source id. [`UpgradeExecutor`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/UpgradeExecutor.java) writes parser documents after the dictionaries, so a parser names the version the run just wrote, and the preview already reports such a parser as UPDATE.
* **A resource named twice is created once.** `extractResourcesUris` returned a URI once per mention, so a dictionary both a parser step and its document named was created twice and the first copy orphaned. It now returns each distinct URI once, for every resource type.
* **Nothing that used to import stops importing.** Every archive written before this carries the parser step without the document. `RestImportService` imports only the parser documents an archive actually carries; one it lacks leaves the step and its reference exactly as before, rather than dropping the step on a `create` (the rule for every other type) or refusing a `merge` that has no local copy to answer it with.
* **Dangling references are healed, not fatal.** An agent promoted before this — or imported from such an archive — names a parser its instance never had. The export now skips a parser document that no longer exists instead of failing the whole backup, and a sync recreates a resource the target's step names but the target does not have, repointing the step. Only a resource the store *confirms* is gone is recreated: a read that failed for any other reason would otherwise swap a live resource for a copy.

None of this changes how an agent runs: the pipeline builds its parser from the workflow step (`WorkflowStoreClientLibrary`) and never loads the document. The document matters to everything else that reads it — the `/parser/{parserId}` endpoint, orphan detection, cascade delete — and to a promotion that is meant to leave no reference behind.

RAG was checked for the same gap and has none: knowledge-base configurations, with their ingestion sources, are exported, imported and synced, and a first promotion recreates their cron schedules. What a knowledge base has *ingested* is data in the vector store and does not travel; [`agent-sync-guide.md`](/conversations-and-orchestration/agent-sync-guide.md) now says what travels, what does not, and how to fill a promoted knowledge base.

**Tests:** `NestedReferencesTest`, `RestImportServiceParserTravelTest`, `RestExportServiceParserTest`, `StructuralMatcherParserTest`, `UpgradeExecutorParserTest` — each behaviour above mutation-checked (the fix reverted, the test confirmed red).

***

## 🐛 fix(rag): findings from a live end-to-end RAG run (2026-09-28)

**Repo:** EDDI (`fix/rag-e2e-findings`)

A live run of RAG and ingestion — a Claude agent answering from knowledge bases on the `in-memory` and `chroma` stores, fed by direct ingestion and by an upload source holding PDF, Word, Excel, PowerPoint, CSV, HTML and Markdown files — passed on both stores, and confirmed on Chroma what [`docs/rag.md`](/agent-configuration/rag.md) listed as unverified: re-ingesting a changed file removes the chunks of its previous version. It also turned up the defects below.

### Deleting an uploaded file answered 400 after it had succeeded

`DELETE /ragstore/rags/{id}/sources/{sourceId}/files/{fileId}?version=N` removed the file and its vectors, then answered `400 "state should be: hexString has 24 characters"`, so the Manager reported a failed delete for one that had happened.

`DocumentDescriptorFilter` runs after every successful DELETE and marks the deleted resource's descriptor as deleted. It took the resource id from the last path segment, and for this endpoint that is a 32-hex file id — valid enough for `RestUtilities.extractResourceId`, not for a MongoDB `ObjectId`. The filter now does its DELETE bookkeeping only for a path of the form `{store}/{collection}/{id}`, which every configuration resource has; anything deeper is a sub-resource whose last segment is not a configuration id.

* `DocumentDescriptorFilter.addressesResourceItself` — the path-shape check.
* `DocumentDescriptorFilterTest` — a sub-resource DELETE leaves descriptors untouched (fails with the check removed), plus the path shapes.

### Direct ingestion can now replace a document instead of duplicating it

`POST /ragstore/rags/{id}/ingest` only ever added: ingesting `pricing.md` a second time kept both versions, and retrieval put the stale text in front of the model next to the current one. The live run showed it on both stores — two identical chunks in the injected context.

`replace=true` (off by default, so existing callers are unchanged) now supersedes whatever was ingested under the same `documentName` in the knowledge base.

* **Add first, remove afterwards** — the same order the ingestion sources use. Every chunk now carries the `ingestionId` that wrote it, and after the new chunks are stored the others under that name are removed with `ingestionId != current`. Chunks written before this change carry no `ingestionId`; the in-memory store and Chroma both match a missing key with `isNotEqualTo`, so those are superseded too (tested against the real in-memory store; checked by hand against Chroma 1.0).
* **Needs an explicit name** — `400` without one, because every unnamed document shares `unnamed` and replacing it would delete all of them.
* **Says so when it cannot** — on a store that cannot delete by metadata the ingestion completes and the status response carries a `warning`, rather than reporting a clean replace.
* **Manager** — the drop zone has a "replace a previously ingested document with the same file name" checkbox (off by default), and the status list shows the warning. Pasted text is always ingested under a fresh name, so it is not offered there.

Files: `RagIngestionService`, `IRestRagIngestion`, `RestRagIngestion`, `ui/manager/src/components/editors/rag-editor.tsx`, the 11 Manager locales, [`docs/rag.md`](/agent-configuration/rag.md#document-ingestion). Tests: `RagIngestionServiceTest` (replace, no replace, pre-change chunks, unsupported store — the first three fail with the removal disabled), `RestRagIngestionTest`.

### Web sources read every sitemap form, including the index most large sites publish

The crawler already seeded from sitemaps listed in robots.txt, but it read every `<loc>` as a page. A **sitemap index** lists further sitemaps, so their URLs were queued as pages, the fetcher refused the XML, and sitemap discovery found nothing — on docs.labs.ai itself, whose `sitemap.xml` is an index over `sitemap-pages.xml` (81 pages). An image or video sitemap would equally have queued every image as a page.

* **Every protocol form** — `urlset` (only `url/loc`; the image, video and news extensions' own `loc`s and `xhtml:link` hreflang alternates are not pages), `sitemapindex` (followed, indexes of indexes included), RSS 2.0 and Atom feeds, plain-text sitemaps, and any of them gzip-compressed — recognised by content, not by name or Content-Type. Namespace prefixes, entities, CDATA, BOMs and UTF-16 are handled; only absolute `http(s)` URLs count.
* **Where sitemaps come from** — new `web.sitemapUrls` (at most 20, each validated like `startUrl`: http(s), not a private literal address) for a site that publishes one without listing it, or a crawl with `respectRobots` off; then robots.txt, where a relative `Sitemap:` line is now resolved instead of silently failing; and, only when neither gave anything, the conventional `/sitemap.xml`.
* **Bounds** — 20 sitemaps per run including every index child, 5,000 page URLs across all of them, 16 MB decompressed per sitemap (a gzip bomb stops at the cap, keeping the URLs before it). Sitemap pages are held to the crawl's scope like linked ones.
* **Manager** — a "Sitemaps" field on website sources, in all 11 locales.

Files: `WebCrawler` (`parseSitemap`, sitemap queue), `CrawlRequest` (`sitemapUrls`; the 4-argument constructor stays), `IngestionSource.WebSource`, `IngestionPipeline`, `ui/manager` ingestion-sources panel and API type, [`docs/rag.md`](/agent-configuration/rag.md#sitemaps). Tests: `SitemapParsingTest` (20 — every form, encoding, gzip bound), `WebCrawlerTest` (index following, configured sitemap without robots, dedupe, index budget, relative robots line, `/sitemap.xml` fallback and its absence when a sitemap is named, gzip end to end, scope), `IngestionSourceSitemapValidationTest`. Mutation-checked: without the index distinction or the gzip branch, six of them fail.

### A failed turn no longer comes back silent

A Claude agent configured with `temperature`, which Sonnet 5 rejects, answered every message with nothing: `POST /agents/{id}` returned `200`, `conversationState: "ERROR"` and an output holding only the actions; the streaming path sent `task_failed` with `"Streaming chat failed"` and a `done` with the same empty output. The reason — "`temperature` is deprecated for this model." — was only in the server log, and the Manager rendered an empty bubble.

* **The caller is told** — without strict write discipline (the default), the failed step's output now carries the same `taskErrors` entry strict write's `digest` mode writes. It is caller-facing only: nothing reads `taskErrors` back into the model's view, and no data or `task_failed_*` action is written, so agent prompts are unchanged. Under strict write the configured `onFailure` still decides — `exclude_all` stays silent.
* **With the actual reason** — `LifecycleManager.describeFailure` appends the most specific cause to the wrapper's message and pulls `message` out of a provider's JSON error body. It feeds both the digest (redacted, URLs removed, 200 chars) and `summarizeForAudit`, so the streaming `task_failed` event and the audit ledger carry the reason too.
* **And it survives the response** — non-detailed responses (the default, and what the Manager and the streaming `done` event carry) keep only whitelisted output keys, and `taskErrors` was not one of them. The entry was written and then stripped, so the fix above did nothing on its own, and **strict write's `digest` mode has never reached a default client either**, whatever `docs/memory-policy.md` said. Found by running the change live; the unit tests, which stop at `LifecycleManager`, passed throughout. `MemoryKeys.TASK_ERRORS` is now on the list. A wrapper that quotes the provider's raw JSON body ("Chat model execution failed: {…}") gets the message swapped in as well.
* **Manager** — both chat paths show `⚠️ <reason>` in place of the empty reply, or a generic line when an `ERROR` turn carries none (`chat.turnFailed`, 11 locales).
* **Chat UI unchanged, on purpose** — it already shows end users a "Something went wrong" banner with Try again on an `ERROR` turn, and the provider's wording is for operators.

Files: `LifecycleManager`, `MemoryKeys`, `ConversationMemoryUtilities`, `ui/manager` `use-chat.ts` and `lib/api/conversations.ts`, [`docs/memory-policy.md`](/architecture-and-concepts/memory-policy.md#a-failed-turn-always-tells-the-caller-why). Tests: `LifecycleManagerTest` (reported without strict write with the unwrapped reason, model view untouched, `exclude_all` silent, redaction — the first and last fail with the report removed), `LifecycleManagerErrorClassificationTest` (`describeFailure`), `ConversationMemoryUtilitiesTaskErrorsTest` (the whitelist), `TaskFailureReportIT` (both paths over HTTP), Manager `conversations.test.ts`, `use-chat-sse-handling.test.tsx`, `use-chat.test.tsx` (all three chat cases fail with the notice removed).

### Integration tests over real HTTP, and ITs next to a local MongoDB

* `RagIngestionIT` — upload, list and **delete** a file through the full JAX-RS filter chain (the layer the 400 lived in, which every unit test skipped), `replace` validation, and `sitemapUrls` persisted and refused at a private address. `TaskFailureReportIT` — an agent whose LLM points at a closed local port: both the plain and the streaming path must carry `taskErrors`. Reverting either fix fails them (`deleteFile`; both failure-report tests).
* `IntegrationTestProfile` follows `-Dquarkus.mongodb.devservices.port`. With a locally installed MongoDB on 27017, DevServices could not bind and every `@QuarkusTest` IT failed at startup; `-Dquarkus.mongodb.devservices.port=27018` now moves both DevServices and the profile's connection string, rather than pointing the tests at the real local database.

### Manager e2e, and document ingestion stuck at "processing" in development

* `ui/manager/e2e/rag-ingestion.spec.ts` (MSW tier) — the Sitemaps field, a dropped file with and without "replace" (the mock answers `replace=true` with a warning, so the test proves the flag was sent), and a failed chat turn on the plain and the streaming path. Each case fails with its fix reverted. The seams live in `src/test/mocks/handlers.ts` (`TASK_FAILURE_TRIGGER`, a new `/agents/:id/stream` mock), because `page.route` cannot see requests the MSW service worker answers.
* **Fixed on the way:** the knowledge-base ingestion panel's `mountedRef` was set `false` by its effect cleanup and never back to `true`. React StrictMode mounts, unmounts and remounts in development, so every status poll returned immediately and every direct ingestion showed "processing" for ever under `npm run dev`. Production builds do not double-mount, which is why it went unnoticed; the e2e spec is what surfaced it.
* The ingestion warning uses the `text-warning` token rather than raw amber classes.

### A dead ingestion run no longer reads as running, or blocks purge and file delete

Only claiming a new run reaped an abandoned one. A crawl killed by a restart therefore stayed `RUNNING` in the run history until somebody started another — observed live an hour past its 25-minute threshold — and, because `activeRun` is also the guard purge and file delete answer 409 on, it refused both for as long. On a source with no cron, indefinitely.

`RagSourceIngestionService.listRuns` and `activeRun` now reap first: the same `reapStaleRuns`, the same per-source threshold (`IngestionPipeline.staleBefore`, now shared by every caller so a read and a claim cannot disagree about which runs are dead), the same fencing. Best effort on a read — a reap that fails is logged and the read proceeds. What it deliberately does not change: before the threshold a crashed run still reads as `RUNNING`, because runs record no owner or heartbeat and a dead run cannot be told from a live one on another instance. Tests: `RagSourceIngestionServiceTest.AbandonedRuns` (history shows it failed, no longer active, a live run untouched, the threshold follows the budget, a failing reap does not break the read — the first two fail with the reap removed).

### Review fixes, and two CodeQL alerts

* **Concurrent replacements of one document could delete each other** (CodeRabbit). Each stores, then removes every other ingestion's chunks under the name; interleaved, both deletions take the other's new chunks and the document is left with none. Store-and-delete is now serialized per (knowledge base, document name) with a weak-valued lock map — per instance, stated in the Javadoc. `concurrentReplacementsOfOneNameLeaveOneVersion` forces the interleaving and ends with zero chunks without the lock.
* **Manager: the streaming back-fill overwrote the failure notice** when an `ERROR` turn also carried text (CodeRabbit). The notice is now appended after it; tested, and the test fails with the old order.
* **`EmbeddingStoreFactory`** — the Chroma builder logged `baseUrl`, tenant, database and collection unsanitized (`java/log-injection`); `sanitizeCollection` stripped trailing underscores with `_+$`, quadratic on a long run of them (`java/polynomial-redos`). Both pre-existing on `main`; now `sanitize(...)` and a linear scan. The new timing test fails against the regex.

### Second review round: a run's deadline is fixed at its claim

* **A lowered time budget could reap a live run** (CodeRabbit, Major). The stale cut-off came from the source's *current* settings, so lowering `timeBudgetMinutes` while a run was live declared it dead — failing it and fencing its worker mid-crawl — and, with reaping now on reads, let purge and file delete proceed underneath it. It predates this PR on the claim path. Each claim now records `staleAfter` (claim time + the budget it runs under + 15 minutes) on the run — a `staleAfter` field in MongoDB, a `stale_after` column in PostgreSQL added by `ADD COLUMN IF NOT EXISTS` — and `reapStaleRuns` judges a run with a deadline by that alone. Runs claimed before the upgrade keep the start-time rule. `IngestionStateStoreContract` gains `recordedDeadlineGoverns` and `passedDeadlineIsReaped`, run against the in-memory, MongoDB and PostgreSQL stores (42/42 each, the latter two in Testcontainers); `reservationRecordsItsDeadline` fails if the pipeline claims without one.
* **CodeQL `java/unreleased-lock`** on the replace lock: the conditional `lock()`/`unlock()` pair is now a plain `lock(); try { … } finally { unlock(); }` on its own branch.
* **Store URLs in logs** (CodeRabbit): `sanitize()` neutralises control characters but kept `user:password@` and query-string tokens. The Chroma and Elasticsearch builders now log `endpointForLog(url)` — scheme, host and port only; a path can carry a token too.

### Third review round: sitemap-index children, and a replacement whose cleanup fails

* **A sitemap index could send the crawler to any host** (CodeRabbit, outside-diff). Its child sitemaps were queued unchecked, so a crawled site could have EDDI fetch sitemaps from arbitrary public hosts (SSRF protection stops private ones only). A child must now be on the index's own host — the sitemap protocol's rule — rather than in the crawl's page scope, which would have rejected `/sitemap-docs.xml` for a source scoped to `/docs/`. Configured and robots.txt sitemaps keep cross-host, which the protocol allows. `sitemapIndexChildrenStayOnItsHost`.
* **A replacement whose delete failed** — pgvector wraps its `SQLException` in a `RuntimeException` — left both versions retrievable behind a bare `failed:` status. It is still reported as failed (a retry removes every version but its own), with a `warning` saying the new version is stored alongside the old. `replace_whoseCleanupFails_failsAndSaysBothVersionsMayRemain`.

### Fourth review round: incomplete sitemap discovery no longer counts as coverage

A crawl whose sitemap discovery was cut short — the 20-sitemap budget with sitemaps still queued, the 5,000-URL cap, a sitemap cut at the per-sitemap or decompression cap, or a sitemap that could not be read for a reason that says nothing about its content — still ended `COMPLETED`, so `coveredWholeSource()` held and deletion reconciliation could remove pages only the unread sitemaps listed (CodeRabbit). It predates this PR for the robots.txt caps; index traversal made it easier to reach. `CrawlSummary` gains `discoveryIncomplete`, which `coveredWholeSource()` now honours; a sitemap answering 404 stays a definite answer, so sites without one keep reconciling. Tests: `unreadableSitemapIsNotCoverage`, `sitemapIndexBudget` (both fail with the flag ignored), `noSitemapIsStillCoverage`, and the parser's cap cases.

* **A sitemap cut short read as complete** (CodeRabbit, fifth pass). The suggested fix — Jsoup's `setTrackErrors` — would have caught nothing: probed against Jsoup 1.23.2, its XML parser reports no error for a body that stops mid-element or for a mismatched end tag. Instead a sitemap is incomplete when the fetch says it was truncated at the 1 MB cap, or when an XML sitemap (`urlset`, `sitemapindex`, RSS, Atom) does not end with its own closing root tag. An HTML page served at `/sitemap.xml` is not a sitemap root, lists nothing and stays complete, so sites that answer that path with a 200 "not found" page keep reconciling. `truncatedSitemapIsNotCoverage`, `truncatedTextSitemapIsNotCoverage` (fails if the truncation flag is ignored), `cutShortIsIncomplete` (fails without the closing-tag check), `htmlPageIsNotASitemap`.
* **…without flagging valid XML as cut short** (CodeRabbit, sixth pass). XML allows comments, processing instructions and whitespace after the root, generators append them, and `<urlset/>` is a valid empty sitemap — the first version of the closing-tag check rejected all three, which would have stopped deletions for such sites on every run. Trailing misc is now skipped first and a self-closing root accepted; a trailing comment that was itself cut off still reads as truncation. `trailingMiscIsComplete`, `selfClosingRoot` (both fail against the strict check), `truncatedInsideTrailingComment`.

***

## 🐛 fix(manager): "Offline" pill and owner-only Delete on detail pages for non-admins (2026-09-28)

**Repo:** EDDI (`fix/manager-editor-status-and-delete`)

Two defects found by running `main` with workspaces enforced (`eddi.workspaces.enabled=true`, Keycloak) and signing in as a user holding only `eddi-editor`.

### 1. The header status pill always read "Offline"

`usePlatformStatus` polls `GET /administration/logs/instance-id`, which is `@RolesAllowed("eddi-admin")`. For everyone else it answers 403, and the hook treated every non-2xx as unreachable. An editor therefore saw a red "Offline" pill for the whole session while using a working backend.

**Decision:** 401/403 now count as online, with no instance ID. We kept the admin probe and did not switch to `/q/health` because:

* a 401 or 403 is still a response from EDDI, and that is the question the pill answers ("can I reach the platform?"). A network error, a timeout or any other non-2xx still reads as offline.
* `/q/*` is not in the Vite dev-server proxy, so under `npm run dev` it would get the SPA's `index.html` with a 200 and report online even with the backend down. `/q/*` is also often blocked at the ingress.
* `/q/health/ready` goes DOWN (503) when a dependency fails. That would turn a degraded database into "Offline", which is a different statement.
* admins keep the instance ID in the popover. Nobody else gets it, and the popover already hides that row when it is null.

### 2. The detail pages offered Delete to EDIT grantees

The agents, workflows and resources **lists** already hide Delete and Share unless `callerLevel` includes `OWN` (`accessFor` in `lib/access.ts`). The matching **detail** pages (`/manage/agentview/:id`, `/manage/workflowview/:id`, `/manage/resources/:type/:id`) did not check the level at all. Someone with only an EDIT grant saw an enabled Delete button, and clicking it returned a 403. None of the three pages has a Share entry point.

* New `accessForDetail(descriptors, id, workspacesEnforced)` in `lib/access.ts` reads the level from the version descriptors each page already loads. Since `main` moved version lists to per-id reads (`GET /descriptorstore/descriptors/{id}?version=`, `getDescriptorVersions`), those are what carry the level. `RestDocumentDescriptorStore` stamps it through `redactUnlessOwner`. It only uses the descriptor whose URI resolves to this `id`, because `filter=` is a text match and can also return a resource whose id merely contains this one.
* **A missing descriptor is not a missing level.** If no descriptor for this `id` comes back, the lookup is empty or partial. That counts as unrestricted only when `/workspaces` reports enforcement off, because unmigrated data there can legitimately return no descriptors and owners must keep Delete. With enforcement on, before `/workspaces` has answered, or when it **failed**, nothing is offered. The pages read the new `useSpaces().enforcement`, which stays `undefined` on a failure. `enabled` folds a failure into `false`, so reading it would turn a 502 into "enforcement off". An older backend's 404 still counts as off. (Both points were raised by CodeRabbit on PR #879.)
* **Nothing is offered while the descriptors load.** Treating "not loaded yet" as unrestricted would show Delete briefly to an EDIT grantee, long enough to click it. Once the descriptors arrive the normal rule applies: a missing level means unrestricted, so deployments without workspaces behave as before.
* `useAgentVersions` now also returns `resource` and `callerLevel` in each entry, so the agent page can use the same helper.

### Tests

* `use-platform-status.test.tsx`: a 401 or 403 reads online with a null instance ID. A network error reads offline.
* `access.test.ts`: `accessForDetail` offers nothing while loading, ignores a descriptor for a lookalike id, and treats a missing level as unrestricted. A missing descriptor offers nothing under enforcement or before `/workspaces` answers, and stays unrestricted with workspaces off.
* New `detail-page-owner-actions.test.tsx`: all three detail pages hide Delete for EDIT and show it for OWN. The agent page also shows it when the backend sends no level. With an empty descriptor lookup, it hides Delete under enforcement and when `/workspaces` fails, and shows it when workspaces are off or `/workspaces` returns 404. The negative cases wait until both the descriptor and `/workspaces` queries have settled, not a fixed delay. Each test waits for content that renders at both levels before checking that Delete is absent.
* `config-editor.test.tsx` and `resources.test.tsx` had checked for Delete synchronously on the first render. They now wait for it, since it appears once the level is known.
* Mutation-checked: with the gates and the 401/403 branch removed, all five new behavioural tests fail. With the empty-lookup case reading as unrestricted again, the tests covering it fail. With the pages reading `enabled` again, the `/workspaces`-failure test fails.

**Files:** [`use-platform-status.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-platform-status.ts), [`access.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/access.ts), [`use-agents.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-agents.ts), [`agent-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/agent-detail.tsx), [`workflow-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workflow-detail.tsx), [`resource-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/resource-detail.tsx)

***

## 🧪 test(manager): no sonner callback outlives a test (2026-09-28)

**Repo:** EDDI (`test/manager-toast-timer-leak`)

### What was wrong

CI's `UI Manager Checks` failed once (run 36418007466, on PR #873) with all 426 files and 6,858 tests passing, because Vitest caught one unhandled error: `ReferenceError: window is not defined`, thrown from React's `dispatchSetState` inside a sonner timer, and attributed to `resource-detail-save-not-live.test.tsx`.

The cause is in sonner 2.0.8 itself. It defers all three of its Toaster updates and cancels none of them on unmount:

1. A **new** toast reaches the Toaster through `setTimeout(() => flushSync(...))`.
2. A **dismissal** is applied in a `requestAnimationFrame`.
3. A dismissed toast is **removed** by `setTimeout(removeToast, 200)` (`TIME_BEFORE_UNMOUNT`).

That test file dismissed its toasts in `afterEach` without waiting, so the shared `cleanup()` unmounted the Toaster with the removal timer still pending. In the file's last test, the timer can fire after Vitest has torn down the jsdom environment. React then reads `window` and throws.

It never reproduced in 12 isolated runs, because it needs a slow runner to lose the race. A temporary probe counting sonner's 200 ms timers is deterministic, though: every run of the file ended with **1** still pending at teardown.

### What changed

* New [`src/test/drain-toasts.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/test/drain-toasts.ts), awaited by the shared `afterEach` in `setup.ts` before `cleanup()`. Every deferred callback comes from a *mounted Toaster's* subscription, so `drainToasts()` gates on the Toaster, not on what it is showing.
  * **With a Toaster mounted, it drains all three paths:** one macrotask (queued insertions), `toast.dismiss()` inside `act`, one animation frame (dismissals), then a wait until no toast is left in the DOM (the 200 ms removals).
  * **A Toaster was mounted earlier in the test and the test unmounted it itself:** its subscription is gone, so there is nothing to dismiss. The callbacks it scheduled before unmounting are still pending, so the drain waits out the longest of them, the 200 ms removal. A `MutationObserver` records every Toaster the test mounts, and `takeRecords()` includes mounts that were removed again before the drain ran. CodeRabbit caught this case on the PR.
  * **No Toaster this test:** nothing ever subscribed, so nothing can be pending. The drain only clears sonner's module-global store, which it does in every case, so one test's toast cannot turn up in the next.
  * **Under fake timers** it advances the clock, because RTL's `waitFor` would otherwise poll a frozen clock.
  * **Why the Toaster, not the visible toast:** CodeRabbit found three cases on the PR where a callback is in flight with nothing visible. The first version of this fix gated on the DOM and missed all three:
    * a toast created in a test's last moments;
    * a toast created and dismissed in the same tick;
    * a toast aimed at another `toasterId`, which a Toaster never renders but still schedules a dismissal frame for.
* [`src/test/__tests__/drain-toasts.test.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/test/__tests__/drain-toasts.test.tsx) tests the actual contract: no sonner callback outlives the drain. It wraps `setTimeout`, `clearTimeout`, `requestAnimationFrame` and `cancelAnimationFrame`, tracks the callbacks scheduled from sonner's code (by stack), and asserts none is still pending after `drainToasts()`. It covers:

  * each of the three cases above;
  * two where the test unmounts its own Toaster first (a pending insertion, and a dismissal queued just before the unmount);
  * a toast already on screen;
  * a store with no Toaster mounted;
  * that the Toaster selector still matches, so a sonner upgrade that changes the container fails loudly.

  Run against the previous version, the two newest cases fail, naming the leaked `setTimeout(0)` and `requestAnimationFrame`.
* `resource-detail-save-not-live.test.tsx`: its own undrained `toast.dismiss()` is gone. While this PR was open, #866 fixed the same flake for this one file by waiting for the removal in the file's own `afterEach`. Merging `main` in, the shared drain replaces that wait: it covers every file that renders a Toaster, and the three callback paths #866's wait for the DOM could not see. That wait is removed and the file's `afterEach` is synchronous again.

This covers all four files that render `<Toaster>` (`resource-detail-save-not-live`, `channel-create-error`, `channel-detail`, `studio-save-flow`), and any later one.

### Verification

* **Probe, the same file:** 1 pending removal timer at teardown before the fix, 0 after, over 3 runs each. The three sibling files also end with 0. The probe was never committed.
* **Full `npx vitest run --coverage`, as CI runs it:** 427 files and 6,866 tests pass, with no unhandled errors. `npm run lint` and `npm run typecheck` pass.

***

## 🔖 chore(release): one command for version bumps, run automatically after every release (2026-09-28)

**Repo:** EDDI (`chore/version-rollover`)

### Why

A release meant hand-editing about a dozen lines outside `pom.xml`: the Helm `appVersion` and image tag, three places in the k8s base Deployment, three in the quickstart, four doc pages, the Manager's `package.json` + lockfile, and a design-sync literal. Whatever was missed stayed wrong. `docs/kubernetes.md` named `6.3.0` through the whole 6.4.0 cycle, and `release-signing.md`'s verification commands still named `6.0.0`, and nothing noticed.

The timing was wrong too. The pom was bumped just before a release instead of right after one, so for a whole cycle main published `6.4.0-b<N>` snapshots *after* 6.4.0 had shipped. A pre-release suffix sorts before the release, so every snapshot looked older than the version it followed.

### What changed

**Fewer copies.**

* Helm: `values.yaml`'s `eddi.image.tag` is now empty and falls back to `appVersion` through a new `eddi.imageTag` helper ([`_helpers.tpl`](https://github.com/labsai/EDDI/tree/main/helm/eddi/templates/_helpers.tpl), used by `deployment.yaml` and `NOTES.txt`). Chart `2.1.0 → 2.2.0`, a non-breaking change: an explicit tag still wins.
* k8s: the base Deployment no longer names a tag. [`kustomization.yaml`](https://github.com/labsai/EDDI/tree/main/k8s/base/kustomization.yaml) pins it through `images:` and sets `app.kubernetes.io/version` through `labels:` (selectors untouched). The Deployment's placeholder, `labsai/eddi:pinned-by-kustomization`, is a tag that does not exist, so applying the file without kustomize fails with `ErrImagePull` rather than pulling `latest`. Every overlay and example was re-rendered with `kubectl kustomize`; the only difference from before is the version label on the base's other resources.
* Manager: `package.json` has no `version` any more. [`vite.config.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/vite.config.ts) reads the first `<version>` from `pom.xml` (the rule `ci.yml` uses) when Maven's `EDDI_VERSION` is not set, so `npm run dev` and the CI UI builds show the right version too. `ds-entry.tsx`'s cosmetic footer string is now `"preview"` instead of a version literal.

**One definition, one command, two checks.**

* [`scripts/release-pointers.json`](https://github.com/labsai/EDDI/tree/main/scripts/release-pointers.json) says where the *published-release* pointers live and what they look like: roots, extensions, prefix exclusions (history and the worked tagging examples) and six line-level patterns. The patterns accept a version that ends a sentence and unquoted or single-quoted YAML, and still refuse `-RC1`, `-b42` and `x.y.z.w`.
* [`scripts/bump-version.py`](https://github.com/labsai/EDDI/tree/main/scripts/bump-version.py) supports:
  * `show` and `check`.
  * `next`, which sets the pom and compares a suffixed pom such as `7.0.0-SNAPSHOT` by its numbers.
  * `release`, which moves every pointer plus the chart version: a minor, or a patch for a patch release, together with `EXPECTED_CHART_VERSION`. `release <current> --force` repairs strays without bumping the chart.
  * `post-release <tag>`, which moves the pom first (so a tag main's pom has not reached still works), then the pointers, and never moves the pom backwards. Both `release` and `post-release` validate every value they will parse or rewrite before the first write, so a refusal never leaves a half-applied bump.
* `ReleaseVersionSourceTest` reads the same JSON. Every pointer must name the chart's `appVersion`, which may never be ahead of the pom. The test also requires:

  * the Helm tag defaults to `appVersion`;
  * the k8s Deployment carries no tag;
  * the Manager's `package.json` carries no version;
  * the pom's first `<version>` is the project's own, with no `<parent>`;
  * CI's path filters trigger the check for every root the JSON sweeps.

  It found the stale `kubernetes.md` line on its first run, and was mutation-checked against it.
* `BumpVersionScriptTest` runs the script against a synthetic repository in twelve tests covering transitions and refusals. It feeds the generated changelog fragment through the collator. It also checks that the Python and Java sweeps find exactly the same pointers in the real repository, since the walk and matching are implemented twice. On CI a missing Python fails it rather than skipping it.
* New `Release Pointers` job in `ci.yml` runs `bump-version.py check` on any change under `docs/`, `k8s/`, `helm/` or `README.md`. Build & Test skips a docs-only PR, so without this job such a PR could merge a stale pointer. The script and the JSON joined the `operator_docs` filter so their Java tests run.

**Automation.** New [`post-release.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/post-release.yml), called by the new `post-release` job in `ci.yml` after `docker`, `smoke-test` and `release` on a stable tag. It can also be dispatched manually, and refuses a version with no tag on `origin` or no image on Docker Hub. It runs `post-release <tag> --changelog` and opens `chore(release): after <tag>` from `chore/post-release-<tag>`. The PR is opened with `RELEASE_BOT_TOKEN`, falling back to `CHANGELOG_BOT_TOKEN` and then `GITHUB_TOKEN`. With `GITHUB_TOKEN` the PR's checks do not start, and the PR body says so. The Slack notification now reports this job's result.

Docs: [`release-versioning.md`](/deployment-and-infrastructure/release-versioning.md) "After a GA Release" rewritten around the two versions, with the major and patch release paths; `build-reproducibility.md` now says where to pin the k8s digest; `release-signing.md`'s commands are release pointers now; AGENTS.md §1 points at the script.

### Decisions

* **Two versions, bumped at different times.** The build version moves right after a release; the release pointers move only once the image exists. Moving them together, before the tag (the old practice), sends quickstart readers on main to a tag that is not published yet.
* **The definition is shared; the implementations are cross-checked.** The script and the test read one JSON for scope and patterns, and a test requires the two implementations to find the same pointers in the real repository, so neither can quietly cover less than the other.
* **A cheap job for docs, not a Java build.** Adding `docs/**` to `operator_docs` would run the whole unit suite on every docs PR to check a handful of version strings.
* **The chart's major is never automatic.** Whether a values file still renders is a judgement call, so a breaking chart change takes `--chart-bump major`.
* **The version label covers the whole base** (Namespace, ConfigMap and Service too), as the recommended-labels scheme intends. It is metadata only, and selectors are excluded.

### Next

* Set `RELEASE_BOT_TOKEN` (or rely on `CHANGELOG_BOT_TOKEN`) so the post-release PR's checks start on their own.
* The next release needs one manual step: `python scripts/bump-version.py next <version>`, because the post-release job did not exist when 6.4.0 shipped.

***

## ✨ feat(workspaces): sharing people can actually use — directory, notifications, space secrets, review (2026-09-28)

**Repo:** EDDI (`feat/workspaces-sharing-ux`)

### Why

A hands-on run of workspaces with three users (alice, bob, carol) on a build of `main` showed that the access model was sound and the experience around it was not. Sharing with an email address stored a grant that reached nobody and still answered 200. There was no way to find what had been shared with you, and the recipient was never told. A 403 showed as "Something went wrong" with a Retry that could never work. Creating anything in a team needed an operator property, and changing that property needed a restart. An editor could neither store their own LLM key nor keep another team from overwriting a global variable. The Operator was unusable for anyone but the person who activated it. This branch fixes all of that; [`docs/workspaces.md`](/security-and-compliance/workspaces.md) describes the result.

### What changed

**Backend**

* **User directory** (`engine/security/spaces/directory/`). Signed-in users are recorded asynchronously, on Mongo and on Postgres. A share now resolves in this order: exact principal, then username, then **verified** email. An unknown or ambiguous name is refused with a message that says why. Grants and owners carry display names, and `GET /workspaces/directory` provides autocomplete. The directory takes part in GDPR erasure and export through a new `IGdprParticipant` hook.
* **Listings** take `ownership=mine|shared`. This is backed by a new `IResourceFilter.NotMatching` filter: `$not` on Mongo, and `COALESCE(...) !~` on Postgres, where a missing field counts as not matching.
* **Create in a space.** A request can name its space in the `X-EDDI-Space` header. `SpaceHeaderFilter` refuses a space the caller is not a member of before anything is written.
* **Runtime settings.** `default-space` and `legacy-visibility` live in `workspace_settings` and can be changed with `GET/PUT /workspaces/settings`. A value set in the properties is pinned and wins; each setting reports whether it is PINNED, STORED or DEFAULT.
* **Sharing changes.**
  * Every change accepts `dryRun`.
  * `PUT .../shares/space` moves a resource into another of your spaces.
  * A new visibility, `internal`, gives USE to anyone signed in.
  * A share now notifies the recipient.
  * `POST .../shares/requests` asks the owner for access. It is limited to 20 per day, and it never reveals whether the resource exists.
* **Notifications** (`workspace_notifications`) are kept for 90 days.
* **Space secrets and variables** (`/spacestore/*`). Each space has a hashed tenant. Members manage it; references read `${vault:<tenant>/k}` and `${vars:<tenant>/k}`. Deploying an agent that references a tenant requires membership of that space (`SpaceReferenceGuard`). Under enforcement, writing a global variable is admin-only.
* **Conversation review.** `AgentConfiguration.conversationReview` is an opt-in per version. Once opted in, the agent's EDIT holders may **read** its conversations, and nothing more. `/agents/{id}/profile` (USE) exposes the notice, and `/agents/{id}/usage` (VIEW) returns counts only.
* **Keycloak**: the compose realm's `eddi-frontend` web origins are now `http://localhost:${EDDI_PORT:7070}` / `https://localhost:${EDDI_HTTPS_PORT:7443}`, and `docker-compose.auth.yml` passes both ports to Keycloak, which substitutes them on import. Before this, running on any port other than 7070 broke browser login with a bare 401.

**Manager / Chat UI**

* The workspace switcher gains ownership filters and a "Created in" hint.
* The share dialog gains:
  * a dry-run preview;
  * directory autocomplete;
  * a warning on grants that reach nobody;
  * `internal` visibility;
  * move to team;
  * a chat link.
* A notification bell, and a request-access panel on detail pages.
* A `/manage/workspaces` page for settings, space secrets and variables.
* A conversation-review editor section and a chat notice, in both chat UIs.
* Operator activation now sets `internal` visibility.
* 101 new i18n keys, in all 11 locales.

### Fixes found on the way

* Ownership transfer from the Manager sent `user:bob` as the new owner id, which matched nobody. It now sends the principal, and the server also strips the prefix.
* The standalone Chat UI (`/chat`) has no sign-in, so on an OIDC deployment it reaches only agents anonymous callers may reach — published ones. Chat links for anything else now open the Manager's chat (`/manage/chat?agentId=…`), which signs people in; the share dialog offers the public `/chat` address only once an agent is published. Found on the live instance, where a `/chat` link to an "everyone signed in" agent hung on "Starting conversation…" behind a 401.
* The share preview rendered unnamed workflows and configs as empty bullets: the server sends `""`, and `name ?? id` only falls back on null.
* A default team applied to everyone, so someone outside it had new work filed where that team could read and edit it; with the setting now changeable at runtime, turning it on for one team moved everybody's new work there. It now applies to the team's members only.
* `requestAccess` answered 202, and `ApiClient` discards the body of a 202, so "you already have access" showed as "sent". It now answers 200 with the outcome.

### Hardening from a critical review, and the merge with main's security batch

An independent review of the branch found these problems; each is fixed here and has a test.

* **A secret could be read by assembling a reference from pieces (high).** Variables resolve before vault references, and every check saw only the pieces. Two variables holding `"${vau"` and `"lt:t.finance…/openai}"` joined into another team's key at runtime, past the deploy-time membership check, the grant check and the API-call reference guard, and a variable could be edited after deploy. `GlobalVariableResolver` now refuses any `${scheme:…}` reference that is not present whole in the value as written or in one substituted variable (`AssembledReferenceException`), and the deploy scan resolves in place so such an agent is refused at deploy. The false sentence in `docs/workspaces.md` that claimed variables were covered is replaced.
* **Space variables holding secret references.** The merge brought main's rule that only an admin may store a variable whose value is a vault or connection reference. `/spacestore/variables` now applies it too.
* **Conversation review showed too much.** Reviewers could read the raw memory document, including the person's long-term properties from other conversations. The raw read is owner-only again; reviewers get the simple log without properties or the detailed view.
* **GDPR missed personal-space secrets and variables.** A new participant erases them and exports them (secrets by name, never by value). Team spaces are untouched.
* **Access requests revealed that a resource existed.** A repeated request answered `ALREADY_REQUESTED`, where an unknown id answered `SENT`. It now answers `SENT`, and every attempt counts toward the 20-a-day limit, not only the delivered ones.
* **Legacy visibility failed open.** It fell back to `shared` when the settings store was unreadable at startup, even if an admin had set `admin-only`. It now fails closed until the store can be read.
* **Tenant hashes were 32 bits.** Two principals that slug alike collided after about 2^32 attempts, and a collision means membership of the other tenant. They are now 64 bits.
* **Smaller fixes:**
  * directory search no longer matches unverified or hidden email addresses;
  * `/workspaces` and the notification endpoints are open to every signed-in role;
  * space variables written by tenant id through `/variablestore` are never exportable;
  * the review-policy cache expires after write;
  * `X-EDDI-Space` is sent only on POSTs that create resources, so a chat turn can no longer be refused because of it.

From CodeRabbit's review of the PR:

* The deploy-time space check now follows `${connection:…}` into the connection document, the way the grant check already did.
* A review setting that cannot be read is no longer cached as "review off". Reading a conversation is denied, and the chat shows the notice.
* A settings update reads the store directly, so a failed read aborts the update and can no longer erase a pinned field's stored value.
* A GDPR participant whose export fails is named in the bundle and makes it incomplete.
* In the notification bell, a failure to mark a request read after a successful grant no longer reports the grant as failed.
* A move into a space is refused unless the target is a well-formed space id (`user:<principal>` or `team:<group>`). Administrators skip the membership check, so a malformed id used to file the whole cascade under a space nobody could hold.
* The deploy-time space check fails closed. A workflow, or the config of a scanned extension (LLM, HTTP calls, MCP, RAG), that cannot be read refuses the deployment instead of counting as "names no tenant".
* The same applies to a connection that cannot be read, and to a store failure while reading the agent itself. A missing agent still answers 404.
* The Chat UI's review notice is cleared and refetched when the chat's target changes, and a slower answer for the previous target cannot overwrite it. The input stays closed until the profile lookup has answered, so nobody can type before being told the conversation may be read.
* That wait now covers every way to send, quick replies and the requested input field included, not only the composer. A managed route (`/chat/managed/:intent/:userId`) names no agent, so it now takes the agent from the loaded conversation and shows that agent's notice.
* The Manager's chat waits the same way: the composer, the requested input field and quick replies stay closed while the agent's profile lookup is out. The notice and the chat read one query (`useAgentReviewProfile`), and a failed lookup settles it, so the chat never blocks on the notice.

Deliberately not changed, and documented instead:

* A sub-agent that inherits a team's key cannot be deployed for a non-member. The guard is right: the chatting user owns the sub-agent and could repoint it.
* Notification retention is applied when a new notification arrives, not by a sweep.
* Still open, as follow-ups:
  * exact principal or username taking precedence over a verified email that looks alike;
  * `internal` snippets being exported with USE;
  * admin-created non-space tenants being reachable from agents;
  * the Mongo usage count scanning every distinct user.

The merge with `main` (PRs 859 to 865) was resolved by hand in:

* `RestGlobalVariableStore`: both restrictions kept.
* `GdprDeletionResult` / `UserDataExport` / `GdprComplianceService` / `McpGdprTools`: `connectionGrants` and the participant maps both kept. The duplicate constructor introduced by the auto-merge was removed.
* `RestConversationStore`: main's strict delete and EDIT gates, plus the review reads.
* `ResourceSharingService`: main's `SharingChangedEvent`, never fired on a dry run, plus moves and notifications.
* `use-agents.ts`, the compose file and the 11 locales.

***

## 🔒 fix(infra): CodeRabbit review findings on PR 865 (2026-09-28)

**Repo:** EDDI (`fix/security-infra`)

### What changed and why

* **GCP: the Keycloak admin password no longer travels in instance metadata.** It was generated on the operator's machine and embedded in the startup script, which GCE serves to every process on the VM (containers included) from the metadata server, and the temp copy was mode 644. The startup script now generates it **on the VM** into root-only `/root/.eddi-keycloak-admin` (once; later boots reuse it) and the banner prints the `gcloud compute ssh … --command='sudo cat …'` to read it. This also retires the `/dev/urandom` fallback that filtered raw bytes and usually came up short of 16 characters.
* **GCP: existing firewall rules are reconciled, not skipped.** A rule left world-open by an earlier run was reused as-is while the summary claimed a scoped firewall; `ensure_firewall_rule` now compares source ranges and updates the rule. The port-80 rule alone is `0.0.0.0/0`, because Let's Encrypt's HTTP-01 validation comes from its own servers — it serves only the ACME webroot and a 301; 443 stays scoped. The now-pointless `:8180` rule is no longer created.
* **Keycloak is published on loopback** (`KEYCLOAK_BIND`, default `127.0.0.1`) in [`docker-compose.auth.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.auth.yml), so its `admin/admin` dev default is never reachable off-host by accident.
* **`install.sh` logs in to Keycloak with the bootstrap credentials** it was started with (`KC_BOOTSTRAP_ADMIN_*`, default admin/admin) instead of a literal admin/admin, so a provisioned VM's CORS/redirect setup no longer silently fails; `eddi update --eddi-version=` validates the tag before writing `.env`.
* **Helm refuses an OIDC-off render without both high-value opt-ins** — the default install otherwise rendered and then crash-looped on `HighValueSurfaceGuard`. Docs' `helm install` lines now carry the required values. A live `helm upgrade` of a release whose MongoDB predates authentication refuses to render until the user is created (`mongodb.authMigrated`, procedure in [`docs/kubernetes.md`](/deployment-and-infrastructure/kubernetes.md)). MongoDB credentials are URL-encoded in the connection string.
* `auto-approve-copilot.yml` also treats a rename **out of** `.github/` as a CI change (`previous_filename`), and fails closed when the changed-file list hits the API's 3,000-file cap.

***

## 📝 docs(governance): release tags carry no `v` prefix (2026-09-28)

**Repo:** EDDI (`docs/governance-bare-release-tags`)

### What changed and why

[`GOVERNANCE.md`](https://github.com/labsai/EDDI/tree/main/GOVERNANCE.md) said releases are tagged `v6.0.0`, `v6.0.1` and `v6.0.0-RC1`. That contradicts AGENTS.md §1, [`release-versioning.md`](/deployment-and-infrastructure/release-versioning.md) and `ci.yml`, which triggers only on tags matching `[0-9]*`. A `v`-prefixed tag starts no workflow at all: no image, no cosign signature, no SLSA attestation and no GitHub release. The push succeeds anyway, so nothing reports the problem. GOVERNANCE.md is where a new maintainer looks for "how do we release", so it was the one place still pointing them at the tag that does nothing.

The page now uses bare tags (`6.0.0`, `6.0.0-RC1`), says why the prefix matters, and links to `release-versioning.md` for the process.

### Checked, not changed

A repo-wide search for `v6.`/`v5.` versions and `git tag` instructions, excluding the changelog, found no other place that tells someone to push a `v`-prefixed tag. The remaining hits are:

* "Available since v6.0.0" status lines, which name a release, not a tag to create.
* `# v6.0.3` comments on pinned GitHub Actions.
* The existing warnings against the prefix, in `release-versioning.md` and `release-signing.md`.

***

## 🐛 fix(ci): the Slack notification exceeded Block Kit's 10-field limit (2026-09-28)

**Repo:** EDDI (`fix/slack-notify-field-limit`)

### What was wrong

`ci.yml`'s `notify-slack` job posts a Block Kit message whose status icons sit in one section block's `fields` array. That array had 11 entries. Slack's section-block reference caps `fields` at 10 ("Maximum number of items is 10"), and a message over the cap is rejected as a whole (`invalid_blocks`), not truncated. The step posts with `curl -sf`, so the result was not a shorter message: no failure or release notification reached Slack at all. The only trace was a failed `Slack Notification` step, and nothing was told about it.

### What changed

* [`ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml): the statuses are split into two section blocks, with every icon kept.

  * Build and test gates: Build & Test, Integration Tests, UI Gate, Build Image, Backend E2E.
  * Scan and publish gates: Secret Scan, Vuln Scan, CodeQL SAST, Docker Push, Smoke Test, Red Hat Preflight.

  The payload is still built by `jq -n` from `--arg` values, with no inline interpolation, and a comment at the payload records the limit.
* [`BuildQualityGatesTest`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/BuildQualityGatesTest.java) `slackSectionsStayWithinTheFieldCap` checks two things:

  * every `fields` array in `notify-slack` has at most 10 entries;
  * every `--arg *_icon` the job computes is rendered in some section, so a later split cannot silently drop a status.

  Mutation-checked both ways: `origin/main`'s workflow fails with "a notify-slack section has 11 fields", and removing the Red Hat Preflight field fails the rendered-icons check.

No test asserted the payload's shape before this. actionlint reports the same 60 pre-existing findings on `ci.yml` before and after the change, and none new.

***

## 🐛 fix(ci): weekly digest reported "(0)" for every metric; make both digests reliable (2026-09-28)

**Repo:** EDDI (`fix/weekly-digest-durable-baseline`)

### What happened

The Monday Slack digest showed every delta as `(0)`: pulls, stars, forks, views and clones. The run logs show why. The Actions cache entry that held the week/day baselines had been evicted; the repository cache sits at its 10 GB cap, so this is routine. A cold-start run at 14:55 UTC reseeded both baselines to the current values. The Monday 07:00 cron, delayed by GitHub to 15:03, then found a baseline that was non-zero but only eight minutes old. The existing sanity check only catches a baseline of **0**, so it could not catch a baseline of "now". The real week (the 2026-09-21 row against 2026-09-28's) was +1,170 pulls, +4 stars and +10,329 clones.

Reviewing the workflow against "both digests must always work" turned up more:

* **A dropped cron lost the digest.** Each digest hung off one `0 7 * * *` / `0 7 * * 1` event, and GitHub delays and drops scheduled runs. The workflow's own comments record a 9.6-hour gap, and today's event arrived 8 hours late.
* **A zero could enter the history.** On a cache miss, a Docker Hub blip carried forward `0`, and `Persist metrics history` wrote it as the day's row. Used as a baseline, that row would read "+400,871 pulls". The GitHub stats step had no guard at all.
* **Daily windows overlapped.** The digest compared live values against a row, giving a window of about 31 hours that overlapped the next day's.
* **A failed history fetch was read as "no branch"** and started a fresh orphan branch.
* **Nothing stopped a double send, and a failed Slack post was never retried.**

### What changed and why

All in [`docker-pull-notify.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/docker-pull-notify.yml):

* **Digests are computed from the `metrics` branch only**, row to row. Daily: yesterday's row to today's. Weekly: the previous Monday's row to this Monday's. Rows are first-run-of-the-day snapshots, so windows are contiguous and a re-run gives the same numbers. A missing past day falls back up to two days, bad (zero) rows are skipped, and the message names the dates actually compared. A window ending today waits for today's row instead of ending early.
* **No digest cron.** Any 15-minute run sends a digest once it is due (daily from 07:00 UTC, weekly from Monday 07:00 UTC). A new `data/digests.json` on the `metrics` branch records the last window sent, so each digest goes out exactly once, however late the first run arrives. A Monday with no runs at all is caught up on Tuesday.
* **Claim, send, release.** The marker is pushed *before* the Slack post and reverted if the post fails. A failed push therefore sends nothing, a failed post is retried by the next run, and a stuck push can never re-post the digest every 15 minutes. An unreadable marker file stops all digests with an error, rather than reading as "never sent". Without `SLACK_WEBHOOK_URL` nothing is planned or claimed, so the digests stay due and go out once the secret is set, instead of being recorded as sent.
* **Only fresh values become history.** The Docker Hub and GitHub stats steps report `ok=false` when they carry a value forward, and `Persist` then leaves the day's row to a later run. The GitHub stats step gets the same digits-only guard as Docker Hub. `git ls-remote` now tells a missing branch (create it) apart from an unreachable one (fail).
* The cache now holds only the previous run's values for the 15-minute analytics delta and the milestone marker; the `week_*`/`day_*` fields and the zero-baseline heuristics are gone.

After merge there are no markers yet, so both digests are due at once: the first run past 07:00 UTC that finds that day's history row sends the daily and the weekly for 2026-09-21 → 2026-09-28 with the real numbers. The row is written only from fresh, numeric metrics; until a run writes it, the digests wait for a later run instead of going out early.

Verified locally: actionlint/shellcheck clean apart from the pre-existing SC2129 style notes; the plan step under 11 fake clocks and histories (normal, already sent, not yet due, dropped Monday, today's row missing, zero row, no activity, forced, empty history, corrupt markers, no webhook); both Slack payloads parsed as JSON; persist and claim end-to-end against a bare copy of the branch (append, same-day no-op, not fresh, unreachable remote).

***

## 🎨 fix(ui): one icon per meaning across the Manager and the Chat UI (2026-09-28)

**Repo:** EDDI (`fix/ui-icon-semantics`)

A review of every icon in both UIs — 208 distinct lucide icons in the Manager, and the Chat UI's emoji and Unicode glyphs — against what each one stands for where it is used. It started from two sidebar entries (Active Conversations and Coordinator) sharing the Activity pulse, and found that pattern repeated, plus three places where an icon lookup was silently falling back.

### Defects, not taste

* **Resource-type icons came from six tables that disagreed.** The resource card knew six of the ten types and fell back to `GitBranch`, so every RAG, MCP, snippet and parser card on a list page was drawn as a rule set. The Resources grid's own map lacked snippets and parsers. The pipeline drew parser, behavior, workflow, dictionary and RAG as the same page glyph. The detail page aliased RAG to the dictionary's book. All of them now resolve through one table, [`resource-type-icons.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/resource-type-icons.ts); `ResourceTypeConfig.icon` (a string key into those maps) is gone.
* **The chat activity panel never showed a step's icon or colour.** SSE `task_start`/`task_complete` carry the task's `getType()` (`langchain`, `behavior_rules`, `httpCalls`, …), and the row looked that up as an `eddi://` extension id. It never matched, so every step rendered as the unknown type in grey. The new `extensionTypeForTask` in [`extensions.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/extensions.ts) maps the backend's runtime types (read from the task classes, not from the mocks, which use `ai.labs.*` ids) to extension types.
* **The Audit trail keyed its badges on `behavior`, `httpcalls` and `propertysetter`**, which the backend never sends, so those entries all showed the default `#`, and MCP and RAG had no entry. Badges now resolve through the same function; the colours stay on the page, and the icons come from the shared table.

### Meaning collisions resolved

| Where                                         | Was                                                                       | Now                                                   | Why                                                                                              |
| --------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Active Conversations                          | `Activity`                                                                | `Radio`                                               | Shared its icon with Coordinator. `Radio` already means "live" on the Logs page                  |
| Coordinator                                   | `Activity`                                                                | `Network`                                             | The queue / message bus                                                                          |
| Approvals, `AWAITING_HUMAN`, every HITL badge | `HandMetal` (🤘)                                                          | `Hand`                                                | A raised palm: waiting on a human. The Chat UI's paused card uses the same                       |
| Caller-supplied credential                    | `Hand`                                                                    | `UserRoundKey`                                        | Frees the hand for HITL                                                                          |
| Groups                                        | `Boxes` in the nav, `Users` on cards and detail                           | `Users` everywhere                                    | One concept, one icon                                                                            |
| User Data                                     | `Users`                                                                   | `UserRoundSearch`                                     | Would have collided with Groups                                                                  |
| Workforce mode                                | `Users`                                                                   | `Briefcase`                                           | The mode switcher sits in the same sidebar as Groups                                             |
| Standing-team workspace                       | `Boxes`                                                                   | `SquareKanban`                                        | It is the team's work board                                                                      |
| Sync                                          | `RefreshCw`                                                               | `ArrowRightLeft`                                      | `RefreshCw` is the Refresh button in 42 files. The Sync page's own header already used this icon |
| Capabilities                                  | `Layers`                                                                  | `Blocks`                                              | `Layers` stays with model cascade                                                                |
| GDPR / Privacy                                | `ShieldAlert`                                                             | `ShieldUser`                                          | `ShieldAlert` is the generic "dangerous request" warning                                         |
| Language picker                               | `Globe`                                                                   | `Languages`                                           | `Globe` is API calls                                                                             |
| User memories                                 | `Brain`                                                                   | `NotebookPen`                                         | `Brain` is the LLM                                                                               |
| Properties                                    | `Settings` / `Database`                                                   | `Tags`                                                | A gear read as "settings"                                                                        |
| Dictionary                                    | `BookOpen`                                                                | `BookA`                                               | `BookOpen` is the Documentation link                                                             |
| MCP calls                                     | `Plug`                                                                    | `ServerCog`                                           | `Plug` is Connections                                                                            |
| Agent status `NOT_FOUND`                      | `Square`                                                                  | `CircleDashed`                                        | The square is the Stop button                                                                    |
| Wizard steps                                  | Identity `Brain`, Model `Settings2`, Review `Rocket`, Template `Sparkles` | `IdCard`, `Brain`, `ClipboardCheck`, `LayoutTemplate` | Each step says what it is. `Rocket` stays on the Deploy action                                   |
| Studio / debugger "Pipeline"                  | `GitBranch`                                                               | `Workflow`                                            | `GitBranch` is Rules                                                                             |

Near-duplicate variants were unified where they meant the same thing: `CheckCircle`→`CheckCircle2`, `Users2`/`UsersRound`→`Users`, `User2`→`User`, `Edit3`→`Pencil`, `Cog`→`Settings`.

### Chat UI: emoji replaced by lucide-react

The widget used 📎 ⏳ 🔒 🔓 👁 🧠 💬 ⏸ next to ◑ ■ ↓ ➤ ▶ ↩ ↪ ↻. Emoji render in colour and differently on every OS, so they ignored the widget's theme colours. The "hidden" eye, 👁‍🗨, is an eye in a speech bubble rather than a crossed-out eye. Send was ➤ in one input and ▶ in the other, and "New conversation" used ↻, which reads as retry. These are now `lucide-react` icons at `size="1em"`, so they follow each control's existing responsive font-size and `currentColor`. The eye shows the action (open eye = reveal), matching its `aria-label`. New conversation is `MessageSquarePlus`, the same as in the Manager. Cost: +9.6 kB raw, **+1.6 kB gzipped**. The lockfile gained only the lucide entries: npm's own rewrite also reshuffled unrelated `peer` flags and line endings, so it was restored and edited by hand, and `npm ci` accepts it.

### Deliberately unchanged

* **Discussion-style and group-template emoji** (🗣️ 😈 ⚖️ …) stay. They are illustrations that tell seven styles apart at a glance, not UI controls, and they appear inside prose strings.
* **`Sparkles`** stays on the Platform Operator and on "AI / auto" affordances. That is the convention, and nothing in the sidebar shares it.
* **The 📎 and ⚠️ inside chat message text** stay. They are message content, and the withdraw-and-restore logic reads them.
* **Deprecated lucide aliases** (`AlertTriangle`, `Loader2`, …) were not mass-renamed. They still resolve in 0.577, and renaming \~100 files would change no pixels and only conflict with open branches.

### Guards

* [`sidebar-icons.test.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/layout/__tests__/sidebar-icons.test.tsx): no two sidebar destinations may share an icon.
* [`resource-type-icons.test.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/__tests__/resource-type-icons.test.ts): the icons in the shared table are distinct, every `RESOURCE_TYPES` slug is covered, and `"constructor"` does not resolve.
* [`chat-activity-icons.test.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/chat/__tests__/chat-activity-icons.test.tsx) and the new `extensionTypeForTask` cases: real backend task types resolve to their icon and colour.

Each guard was mutation-checked: it fails with the old icon or the old lookup put back, and passes on the fix.

**Verification:** Manager — typecheck, lint (0 warnings), **6,875 tests / 429 files**, i18n check. Chat UI — typecheck, **278 tests**, production build. Checked in a browser against MSW mock data: all 26 sidebar entries report distinct icons in the live DOM (collapsed and expanded), the Resources grid shows ten distinct type icons, and the Chat UI composer, secret mode, header and empty state render in the theme colour. The icons that only appear mid-conversation (undo/redo, stop, paused card, thinking indicator) were covered by the component tests only, not checked visually, because that needs a backend.

***

## 🐛 fix(manager): review follow-up on the operations pages (2026-09-27)

**Repo:** EDDI (`fix/manager-ops-pages`), Manager UI only. Answers CodeRabbit's review of PR #854; each fix has a regression test that fails with the fix reverted.

* *Debugger live log (*[*`live-log-viewer.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/debugger/live-log-viewer.tsx)*).* Closing the stream on a scope change did not stop a history fetch already in flight, so a slow reply for the previous agent or conversation merged its lines into the new one's log. The effect now marks itself cancelled on cleanup and drops such a reply.
* *Workflow editor (*[*`workflow-steps.ts`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/workflow-steps.ts)*).* The insertion scanned only for trailing templating steps, so on an already misordered workflow (`[templating, output]`) "Add Task" still appended after every templating step. A new step now goes in front of the **last** templating step (and any templating steps directly before it), and is appended only when there is none. Not in front of the *first*, as the review proposed: on a workflow that also templates mid-pipeline, that would move the new step ahead of the rules whose actions it reads.
* *Conversation monitoring (*[*`conversation-monitoring.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/conversation-monitoring.tsx)*).* The confirm button is disabled only after a re-render, so a quick double click sent `/end` twice. A synchronous ref guard now blocks a second entry; it is released on every path that does not submit and otherwise when the mutation settles, errors included.
* *Coordinator (*[*`coordinator.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/coordinator.tsx)*).* A dropped stream's last snapshot was still the final fallback, so with the status read failing too the page showed a frozen "CONNECTED" instead of the error state. The snapshot is now used only while the stream is up. A failed dead-letter **refetch** also showed the cached list — including a cached empty one as the green "No dead-letter entries" — because TanStack keeps the last good data next to the error. The error state now wins over cached data, and "Purge All" is hidden while the read is failing.
* *Sync (*[*`sync-page.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/sync-page.tsx)*,* [*`sync-config-panel.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/agents/sync-config-panel.tsx)*).* A new preview cleared only the checked mappings' previews, so a mapping unchecked before a failed run re-armed "Sync Selected" with its old diff when checked again; every preview is now cleared. A connect request still in flight when the URL or token was edited later called `onConnected` and refilled the list the page had just cleared, for the old source. Each edit now starts a new request generation, replies from an older one are dropped, and the pending request is reset so Connect is usable again straight away. Editing the token now also clears the "Connected" badge, as editing the URL already did.

***

## 🔒 fix(engine): authorize managed-agent calls before creating conversations (2026-09-27)

**Repo:** EDDI (`fix/managed-agent-auth-first`)

### What changed and why

`GET` and `POST /agents/managed/{intent}/{userId}` (`loadConversationMemory`, `sayWithinContext`) resolved the caller's managed conversation first and checked the caller afterwards. Resolving it has side effects: when no conversation exists it starts one in the engine and stores a `UserConversation`, and when the existing one has ended it deletes that `UserConversation` and creates a replacement. Only then did `checkUserAuthIfApplicable` throw `UnauthorizedException` for an anonymous caller (OIDC on, non-production environment). An anonymous request could therefore create or replace a conversation and still get a 401.

That ordering is why the Manager's `ApiClient` stopped replaying 401'd writes in PR #855 (`fix/manager-shell`): its retry assumed a 401 meant the handler never ran, and this endpoint was the counterexample. The client-side guard stays; this is the server-side fix.

`initUserConversation` now authorizes before each side effect:

* **No conversation yet:** the deployment is picked from the intent's trigger first (`selectAgentDeployment`), the caller is checked against that deployment's environment, and only then is the conversation started, with that same deployment. The random pick among a trigger's deployments happens once, so the check and the start cannot disagree on the environment.
* **Ended conversation:** the same, before the delete. The replacement's environment decides, not the ended conversation's, because the replacement is what the request acts on. That matches what the old code eventually checked.
* **Live conversation:** checked against its stored environment. Only reads (the `UserConversation`, the engine's conversation state) come first.
* A final check on the conversation actually returned still runs. It also covers a conversation that a concurrent request stored first (the `ResourceAlreadyExists` fallback).

`sayWithinContext`'s generic `catch (Exception)` turned the `UnauthorizedException` into an opaque **500**. It now calls `response.resume(e)`, which RESTEasy Reactive routes through the exception mappers (`AsyncResponseImpl.resume(Throwable)` calls `handleException`). So the POST answers **401**, exactly as the GET's throw does.

`endCurrentConversation`, `undo`/`redo` and `isUndoAvailable`/`isRedoAvailable` already read, then checked, then acted, so they are unchanged.

### Behaviour for authorized callers

Authorized callers see the same calls, the same creation order (delete, engine start, stored `UserConversation`) and the same responses. One call moved: when an ended conversation is replaced, the trigger is now read *before* the delete rather than after it. So a missing trigger now fails without deleting the ended `UserConversation`, where before it deleted it and then failed. The error itself is the same: the store's not-found exception for the GET, the opaque 500 for the POST. Every later request fails the same way either way.

### Residual

* **Race path:** if a concurrent request stores its conversation between this request's start and store, this request's engine conversation is orphaned (as before), and the final check runs against the other request's environment. A 401 there follows an engine start. This needs a trigger with mixed environments and a concurrent anonymous request, and the stored `UserConversation` is untouched.
* The HTTP layer normally rejects anonymous callers first. `IRestAgentManagement` is `@RolesAllowed` and the catch-all path policy is `authenticated`, so this in-method gate is defence in depth. It is reached when `authorization.enabled` or the path policy has been loosened.

### Tests

`RestAgentManagementExtendedTest` has a new nested class, "Authorization before side effects", with 20 cases. Anonymous callers with OIDC on and a non-production environment get a 401 with no `createUserConversation`, `deleteUserConversation` or engine start, for both methods, with no existing conversation and with an ended one. Authorized callers are parameterized over authenticated/test, anonymous/production and OIDC-off, for both methods and both the create and replace paths, and each is checked for call order. The missing-trigger error path is also covered. The mutation check was run three ways. Removing the two pre-creation checks fails the 4 create/replace rejection tests. Removing the `resume(e)` branch fails the 3 POST rejection tests. The original class fails 5, and every authorized-path test passes against it.

**Files:** [`RestAgentManagement.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java), [`RestAgentManagementExtendedTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/engine/internal/RestAgentManagementExtendedTest.java), [`security.md`](/security-and-compliance/security.md) (RestAgentManagement Gate: the check now runs before any side effect)

***

## 🐛 fix(engine): start managed conversations with a per-request copy of the trigger context (2026-09-27)

**Repo:** EDDI (`fix/managed-agent-auth-first`, second commit)

### What changed and why

When `RestAgentManagement.createNewConversation` started a managed conversation, it wrote the caller's language straight into `agentDeployment.getInitialContext()`. That deployment belongs to the `AgentTriggerConfiguration` returned by `RestAgentTriggerStore.readAgentTrigger`, which is the instance held in the shared `agentTriggers` cache, not a copy. Its `initialContext` is a plain `HashMap`. As a result:

* concurrent requests raced on one map, so a conversation could start with another user's language;
* a request without a language replaced an earlier value with a null-valued `lang` context;
* the `HashMap` was written from several request threads without synchronization;
* the cached trigger drifted from the stored one until its cache entry was replaced.

Each request now copies the deployment's context (`new HashMap<>(initialContext)`, or an empty map when the deployment has none), puts `lang` into the copy, and passes the copy to `startConversationWithContext`. The trigger in the cache is never written to.

**Null language:** kept as it was for the engine. With no language, the engine still receives a `lang` entry of type `string` whose value is null. Leaving the key out would change what a started conversation sees in its context. What was wrong was writing that entry into the shared map, not sending it.

A deployment whose `initialContext` is null (possible for a stored trigger that omits the field) used to throw an NPE on the `put`. It now starts the conversation with a map that holds only `lang`.

### Tests

The new nested class "Trigger context is copied per request" in `RestAgentManagementExtendedTest` covers five paths:

* `loadCreate`, `sayCreate` (language taken from the input context), and `loadReplaceEnded`;
* `nullLanguage`: the engine still receives a null-valued `lang`;
* `nullInitialContext`.

Each test asserts two things:

* the deployment's `initialContext` equals its value before the call and has no `lang` key;
* the map captured on `startConversationWithContext` is a different instance, still carries the designer-set entry, and has `lang` with the expected value.

**Mutation check:** restoring the in-place `put` fails all five tests. Four fail on "the cached trigger's context was modified", and `nullInitialContext` fails with the NPE.

**Files:** [`RestAgentManagement.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java), [`RestAgentManagementExtendedTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/engine/internal/RestAgentManagementExtendedTest.java)

***

## 🐛 fix(manager): import dialog kept the old instance's agents after the sync source changed (2026-09-27)

**Repo:** EDDI (`fix/manager-import-sync-reset`)

### Why

The Import dialog's "Sync from remote instance" path wired the URL and token fields straight to their setters. The remote agent list, the source agent and version picked from it, the local target and any preview built on them were only cleared when the dialog closed. After connecting to instance A, editing the URL or the token left A's agents on screen with "Preview Changes" still armed, so A's agent could be previewed and imported against instance B.

The Sync page already handles this on the ops-pages branch (`handleSourceChange` in `sync-page.tsx`). The dialog did not.

### What changed

[`import-agent-dialog.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/agents/import-agent-dialog.tsx): a URL or token edit now goes through `handleSyncSourceChange`, which mirrors the Sync page. It applies the edit, then, if anything was derived from the previous connection, clears the remote agent list, source agent and version, sync target, the preview and the state built from it (selection, expanded diff, workflow order), and the step error. It also resets the sync preview and execute mutations. As on the Sync page, nothing is reset when there is nothing to drop.

Resetting the preview mutation also detaches a preview that is still in flight. In TanStack Query v5, `reset()` removes the observer, so that request's per-call `onSuccess` no longer fires, and a reply for the old source cannot move the dialog onto the preview step.

[`sync-config-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/agents/sync-config-panel.tsx): the connect request the panel sends is now tied to the URL and token it was sent with. Each edit starts a new generation, and a reply or error from an older one is dropped. Without this, a connect reply that arrived after an edit filled the list the dialog had just cleared with the old instance's agents. This hunk is ported byte-for-byte from the ops-pages branch (#854), together with its panel test, so the two branches merge cleanly in either order and neither has to wait for the other.

**Tests:** [`import-agent-dialog-sync-source.test.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/agents/__tests__/import-agent-dialog-sync-source.test.tsx) runs against the real hooks and the MSW sync handlers. The sibling test file mocks the whole backup hook module. The new tests:

* A URL edit and a token edit after connect, pick and preview each drop the list and the selection. After reconnecting, the source and target selects come back empty and "Preview Changes" stays disabled.
* A preview still in flight when the URL changes does not land.
* A connect reply still in flight when the URL or the token changes does not land.

The in-flight tests hold the MSW reply behind a gate that exists from the start. They wait until the request has reached the handler before editing, and until the reply has left it before asserting, so the stale reply is really delivered and the test cannot pass on a reply that was never sent.

Mutation check:

* Reverting the dialog fix fails the three dialog-state tests.
* Reverting the panel guard fails both connect tests.
* Dropping only the preview mutation reset fails the in-flight preview test.

***

## 🔧 chore(ci): gate docker on the auth tier, pin and verify Node, harden the changelog guard (2026-09-26)

**Repo:** EDDI (`chore/ci-hardening`)

A batch of CI and build-tooling fixes from the 2026-09-25 whole-repo review (fix-plan item 17). No runtime code changed; nothing here touches branch protection or secrets.

### What changed and why

* **B2: docker waited on neither the auth tier nor the E2E gate.** `docker` publishes, tags, signs and attests the image, but its `needs` listed `e2e-fullstack` and not `e2e-auth`. A push to main whose only failure was the Keycloak tier still shipped. It now needs `e2e-auth` and `e2e-gate`. `BuildQualityGatesTest.dockerNeedsEveryE2eJob` checks that every job the gate grades, plus the gate itself, is in docker's `needs`.
* **B3: fragments passed the Java test but failed the collator.** The Changelog Discipline job now runs `python3 scripts/collate-changelog.py --check` on every PR. `ChangelogFragmentTest` also mirrors the three collator refusals it missed:

  * text above the first heading
  * a fence that never closes
  * a register block spelt almost right (`decision_log`, `Regression-Notes`)

  These are tested with synthetic fragments in `collationProblemsMirrorTheCollator`.
* **B6: the in-place guard could be evaded.** It counted only dated `## … (YYYY-MM-DD` headings, so an undated heading or a setext (`---`-underlined) heading went through. The whole job was also skipped for any head branch *named* `chore/collate-changelog`, a fork's included.
  * The guard now counts every `##` heading, which is what `split_sections` treats as an entry, plus setext heading pairs. Added and removed counts still net against each other, so edits and rotations pass.
  * Like the collator, it skips lines inside fenced code blocks: a quoted `## Example (date)` or a YAML `---` in an example is not an entry. The job diffs with `--unified=1000000`, so the one hunk is the whole file and the awk tracks fences from line 1 on each side. With git's default three lines a hunk could begin inside a fence: a heading quoted in the example was rejected, and a closing fence read as an opening one hid a real entry added right below it.
  * The job runs on every PR. Only a same-repository `chore/collate-changelog` branch skips the guard, and that branch gets its own step that fails if it changes anything besides `docs/changelog.md`, `docs/changelog.d/*`, `docs/changelog/*.md` and `docs/SUMMARY.md`.
  * `ChangelogDisciplineJobTest` pulls both steps' shell out of `ci.yml` and runs it under bash, with a stub `git` that prints canned diffs, and against a real two-commit repository for the fence cases that depend on how git cuts hunks. It tests the workflow's actual code, not a copy.
* **B5: Node was floating and never verified.** Every `setup-node` said `22`, while `pom.xml` vendors `v22.23.2` for the shipped UI build. frontend-maven-plugin 1.15.1 downloads Node without any checksum and reuses whatever archive sits in the local Maven repository, which setup-java restores from cache.
  * A workflow-level `NODE_VERSION` now feeds every `setup-node`.
  * Build Image places the archive at the path the plugin's `RepositoryCacheResolver` resolves (`~/.m2/repository/com/github/eirslett/node/<v>/node-<v>-linux-x64.tar.gz`) and verifies it against `NODE_SHA256_LINUX_X64`, taken from nodejs.org's `SHASUMS256.txt`. A cached copy that doesn't match is fetched again, and the fresh download must match too.
  * Verified locally: the plugin unpacked the pre-seeded archive and downloaded nothing.
  * `BuildQualityGatesTest.nodeVersionIsPinnedOnceAndVerified` keeps ci.yml, `pom.xml` and `mise.toml` in step.
  * A local `./mvnw package` still trusts nodejs.org over TLS, as documented in `pom.xml`. So does `src/main/docker/Dockerfile.demo`, which runs `mvn package` without `-DskipUi`. It is not the shipped image, and fix-plan item 16 (B1) is reworking that file, so it is documented here rather than changed.
* **B4: `.gitignore` re-included nested `.claude/` directories.** `!.claude/` was unanchored, while `.claude/*` is root-only, so `ui/chat/.claude/settings.local.json` was not ignored. The three root rules are now anchored with `/`. The Manager's `ui/manager/.claude/` had the same shape: the whole directory was re-included, so a `worktrees/` checkout created there was addable. It is now re-excluded apart from `skills/` and `launch.json`. `nestedClaudeDirectoriesStayIgnored` asks `git check-ignore` directly. `.gitignore` joins the `backend` path filter so a change to it runs that test.
* **B7: vitest security updates were not grouped.** A group without `applies-to` covers version updates only. Both UIs now have a `vitest-security` group (`applies-to: security-updates`, `vitest` + `@vitest/*`) declared ahead of the catch-all. This has a limit: Dependabot security updates touch only packages that carry an advisory, so a fix for `vitest` alone can still leave `@vitest/*` behind. The comment in `dependabot.yml` says so and says what to do then.
* **ui/chat had no ESLint.** Landed on `main` first (#856, `4fdf4bedd`) while this branch was open; this branch now carries `main`'s config, scripts, lockfile and CI step unchanged — see the reconciliation entry below.
* **NUL bytes in Manager sources.** `ui/manager/src/lib/api/groups.ts:1622` held a literal U+0000 in a string, and so did `ui/manager/scripts/audit-prod.mjs:99` (found by the new guard). Both are now written `\u0000`, so the runtime value is identical. `src/__tests__/source-control-chars.test.ts` fails on any raw control character under `src/`, `e2e/` and `scripts/`.
* **L7: README dev commands lacked the Jlama flag.** Every `quarkus:dev` command in README, AGENTS.md, CONTRIBUTING.md, `docs/getting-started.md` and `docs/developer-quickstart.md` now passes `-Djvm.args=--add-modules=jdk.incubator.vector`, as `mise run dev` does, with a note that only `jlama` agents need it. `JlamaRuntimeFlagsTest.documentedDevCommandsPassTheFlag` checks all of them except CONTRIBUTING.md, which is in no `backend` path filter, so `BuildQualityGatesTest` forbids a test reading it.
* **A5: realm test gaps in `DeploymentManifestsTest`.**
  * A client that omitted `standardFlowEnabled` was treated as unable to log in. Keycloak defaults that flow on, and bearer-only clients are now the explicit exemption.
  * The audience check also counts clients whose only token-minting flow is the device authorization grant (`oauth2.device.authorization.grant.enabled`, or the top-level `oauth2DeviceAuthorizationGrantEnabled`) or CIBA (`oidc.ciba.grant.enabled`).
  * The loopback-redirect check was a prefix test that accepted `http://localhost.attacker.com`. It now requires a loopback host.
  * Both checks get negative tests.
* **Slack notification is best effort.** `curl -sf` failed the job with exit 22 whenever the webhook refused the post, so every failing run showed a second red job. A delivery failure is now a warning in the run summary. Failing PRs from this repository still notify: the job's documented policy ("only failures and releases notify") covers them deliberately, so PR notifications were kept rather than skipped. An empty secret was already skipped.

### Decisions

* AGENTS.md §2 rule 8 and `docs/changelog.d/README.md` describe the new guard and the `--check` step.
* The Node-pin test requires every workflow that uses `env.NODE_VERSION` to declare it itself, at the pom's value. An undeclared one would silently be empty.
* The changelog guard stayed inline bash in `ci.yml`, not a separate script. It is tested by executing the workflow's own step, so there is still one copy.
* The frontend-maven-plugin upgrade (2.x) was not taken. Pre-seeding a verified archive closes the gap for the build that ships without changing the plugin.

**Files:** [`ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml), [`dependabot.yml`](https://github.com/labsai/EDDI/tree/main/.github/dependabot.yml), [`BuildQualityGatesTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/BuildQualityGatesTest.java), [`ChangelogDisciplineJobTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/ChangelogDisciplineJobTest.java), [`ChangelogFragmentTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/ChangelogFragmentTest.java), [`DeploymentManifestsTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.java), [`JlamaRuntimeFlagsTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/JlamaRuntimeFlagsTest.java), [`groups.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/groups.ts)

***

## 🐛 fix(chat-ui): streaming contract, environment, secret input, rendering and a flaky focus test (2026-09-26)

**Repo:** EDDI (`fix/chat-ui`) — UI review 2026-09-25 items High 6 (Chat UI side), High 12, High 13 and the Chat UI Medium/Low items; fix-plan item 26.

### What changed and why

**Streaming and the backend contract**

* **High 12, extra space per token.** `RestAgentEngineStreaming.padDataLines` prefixes every `data:` line with one space, and the parser kept it, so every token gained a space ("quota tion"). The parser in [`chat-api.ts`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/api/chat-api.ts) now strips exactly one; a token's own leading space follows it. The test that enshrined the old behaviour is rewritten.
* **High 13, environment ignored.** `startConversation` now sends the route's environment as `?environment=`; the backend defaulted a missing one to production, so `/chat/test/{id}` returned 404 for a test-only agent and silently used production for an agent deployed in both. A failed start now says why in the transcript instead of staying on "Starting conversation…".
* **Streamed refusals.** An `error` frame carrying a pre-turn refusal `code` (`awaiting_approval`, `conversation_ended`, `agent_not_ready`, …) is handled like a 409: the optimistic bubble is withdrawn and the draft handed back. The code list is pinned against the Java source by `sse-events.review.test.ts`. A mid-turn failure (no code) still stays as an error on the sent message.
* **`rerunLastStep` no longer sends `language=en`.** The backend already treats it as optional; the hard-coded value became the conversation's `lang` property.

**Secret input (High 6, Chat UI side)**

* A requested `inputField` is read from the streaming `done` frame too, not only the non-streaming snapshot, so the password field appears on the default transport and the key is sent with `secretInput`.
* Only `subType: "password"` is masked and flagged secret; `text` and `email` are ordinary fields (`SecretInput` used to mask every subType).
* A refused secret goes back into the composer **masked, with secret mode on** instead of being dropped.
* Rebuilt transcripts (undo, redo, reload) mask a secret turn from the turn output's `input` (`<secret input>`). That only works because of the engine fix below.

**Backend changes the Chat UI findings required**

* **A client-flagged secret input leaked for any agent with a parser.** `Conversation.storeUserInputInMemory` wrote `<secret input>` as the displayed `input`, but `InputParserTask` — the first task of practically every workflow — overwrote it with the normalized plaintext, and `input:initial` was never masked. Only a `scope:"secret"` property scrubbed them. So a key sent with `secretInput` stayed in the stored step, in every snapshot, in the audit ledger (`TurnAuditBuffer` redacts only when `input:initial` reads as the placeholder), and in the streaming `done` frame once the `input` key was exposed. [`Conversation.scrubSecretClientInput`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/Conversation.java) now runs when the turn stops (completed, stopped, paused or failed), before the audit flush:

  * it sets `input:initial` and `input:normalized` to the placeholder;
  * it clears the parsed expressions and intents derived from the secret;
  * it re-asserts `<secret input>` as the displayed `input`;
  * it removes the raw and normalized text (4+ characters) from every other datum and output of the step, and from the pending tool-call batch of a tool-call pause (the transcript the model saw, the gated arguments and the redacted arguments an approver is shown), so a resume replays and executes with the placeholder;
  * the audit ledger records the placeholder and redacts both forms (`TurnAuditBuffer.addSecretInputForms`, same 4-character floor).

  Tasks still see the plaintext while the turn runs. A conversation property the designer captured the input into is deliberately kept (the wizard pattern; the `secret` scope vaults it instead). A task that runs after a HITL resume of the turn sees the placeholder. Tests: `ConversationSecretClientInputTest`, with the real `InputParserTask` in the workflow, including a tool-call pause and its resume (batch, resumed step and audit ledger).
* **Read-time masking of older stored secret turns.** [`ConversationMemoryUtilities.convertSimpleConversationMemory`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java) masks `input` in the outputs and `input:initial`, `input:normalized` and `expressions:parsed` in the steps of any turn flagged `secretInput` (the output's `context.secretInput` / the step's own `context:secretInput` datum, so drifted documents cannot mis-pair), in both detail levels, and removes the raw and normalized text (4+ characters) from every other step result and output value it returns for such a turn — an output item that echoed the input included; conversation properties and their step mirrors are returned as captured. That covers turns stored before the turn-end scrub, which on main already returned the raw `input:initial`, without a migration. The stored document is not rewritten. Tests: `ConversationMemoryUtilitiesTest.oldStoredSecretTurnIsMaskedOnRead`, `oldStoredSecretTurnEchoesAreScrubbedOnRead`.
* **`PropertySetterTask` no longer stores or vaults the `<secret input>` placeholder.** A capture that runs after a HITL resume of a secret turn reads the already-scrubbed input; `scope:"secret"` used to vault the literal placeholder as the secret. It now logs a warning and leaves the property unset. Tests: `PropertySetterTaskSecretScrubTest.placeholderIsNeverVaulted` (main's, which covered the same case), `placeholderIsNotStoredAsConversationValue`.
* [`ConversationMemoryUtilities.convertSimpleConversationMemory`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java) now keeps the exact `input` key in non-detailed `conversationOutputs`. Its contract is "the masked display copy": `<secret input>` for a turn flagged `secretInput`, the normalized text otherwise. It is additive; readers look up specific keys. The Manager's `snapshotToMessages` (from `fix/manager-chat`) reads this key, and the engine fix above is what makes that correct too.
* [`RestAgentManagement.loadConversationMemory`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java) re-runs the last step only when the caller asked for a language. A load without `?language=` compared the stored `lang` against null and re-ran the step on every widget load.

**Conversation lifecycle**

* Managed **New conversation** ends the current managed conversation (`POST /agents/managed/{intent}/{userId}/endConversation`) before loading. Before, the load returned the same conversation.
* Start and restart share one `openConversation`, which re-checks the conversation generation after every await. The first load had no such check, so a restart during its welcome read could get the old greeting and id.

**Rendering**

* Links open in a new tab (`rel="noopener noreferrer"`). A link used to navigate the widget, or the whole iframe, away. Same-page anchors and GFM footnote references stay in the page.
* A model-chosen image (markdown or raw `<img>`) is never fetched: it is a beacon, and only EDDI's own CSP blocked it; an embedding page's CSP might not. After the merge with main it renders as a click-to-open link (main's override) rather than being stripped by the schema.
* `image` output items are rendered by the widget, http(s) or same-origin only. `applicationLink` becomes a link (label escaped, destination wrapped in `<…>`) and `button` shows its label. All three used to be dropped silently.
* KaTeX and syntax highlighting (advertised in the README, never wired) now work. Math is `$$…$$` only (`singleDollarTextMath: false`): with single-dollar math, a reply quoting "$5 and $10" rendered the span between the prices as a formula. Both are **loaded on first use** from their own chunks (`markdown-plugins.ts`), so the main bundle stays about the same (645 → 652 kB). The KaTeX fonts go to `fonts/`, which the Maven build already copies and prunes.
* `MessageBubble` takes its flags as props instead of subscribing to the store, so a streamed token no longer re-renders and re-parses every bubble.

**Smaller items**

* The transcript is `role="log"` with `aria-live="polite"` and `aria-busy` while a reply streams.
* `useTheme` guards every storage access. Blocked storage used to throw and blank the widget.
* `?token=` is read once and removed from the address bar.
* The agent name comes from `/descriptorstore/descriptors/{id}/simple?version=N` with the bearer token. The old call went to `/agentstore/agents/{id}` without auth or version, and that response has no name.
* ESLint (the same base as the Manager) with a zero-warning `lint` script and a `Lint` step in the `UI Chat` CI job.

**Flaky test: "moves focus to the attach button when the last chip is removed"**

* Root cause: the focus intent was consumed by the first `pendingAttachments` effect to run. The upload resolves outside `act`, so React commits the new chip but defers that commit's passive effect to a scheduler task. When the remove click landed before that task ran, React flushed the stale effect first. That effect took the intent while the chip was still mounted and focused the remove button that was about to unmount, so focus fell to `<body>`. Whether the task ran first depends on Node's `setImmediate` vs `setTimeout(0)` ordering, which changes under CPU load.
* Fix: the intent now carries the removed `storageRef` and is only acted on once that chip is gone. A new test forces the race deterministically by clicking natively the moment the chip appears. It failed 5/5 before the fix.

**Review follow-up (CodeRabbit, 2026-09-27)**

* **Short client-flagged secrets were not searched for.** The echo search used the 8-character floor of secret *context* values, so a 5-character PIN a template echoed stayed in the stored step and the reply, while `TurnAuditBuffer` (floor 4) already redacted it from the ledger. Client-flagged input now uses its own floor, `SecretValueScrubber.MIN_SEARCHED_SECRET_INPUT_LENGTH = 4`, shared by the turn-end scrub, the read-time masking and the audit buffer. Not every nonempty form, as suggested: the search replaces every occurrence in every value **and map key** of the turn, so a one- to three-character "secret" would shred the reply and rename the fields of its stored API responses and output items; such a form is still replaced wherever it *is* the input. Test: `ConversationSecretClientInputTest.shortSecretIsScrubbedFromEchoes`.
* **Read-time masking now scrubs echoes** in older stored secret turns (above).
* **Read-time masking now clears the parser's results** for an older stored secret turn, as the turn-end scrub does: `expressions:matches`, `intents` and `properties:extracted` read as empty lists, and the output's `expressions` and `intents` entries are dropped. Only `expressions:parsed` was cleared before; the rest were only searched for the input, and the parser can split a secret into tokens that no search for the whole value matches. Test: `ConversationMemoryUtilitiesTest.oldStoredSecretTurnParserResultsAreClearedOnRead` (it fails with the change reverted).
* **The `CATCH_ANY_INPUT_AS_PROPERTY` capture skips the placeholder too.** It reads `input:initial` directly and stored `user_input`, so after a RULE-pause resume of a secret turn it saved the literal `<secret input>`. It now goes through the placeholder guard (`isScrubbedPlaceholder` since the merge with main) like the `valueString` and `fromObjectPath` captures. Tests: `PropertySetterTaskSecretScrubTest.catchAnyInputSkipsThePlaceholder` (fails with the guard removed) and `catchAnyInputStoresOrdinaryInput`. The helper's Javadoc no longer sits between `autoVaultSecret` and its own doc comment.
* **A resume cannot redact what it reintroduces — documented, not "fixed".** The resume is a later request and the plaintext is never kept past the pause. Restoring it would mean persisting it (defeats the scrub) or a hash (a PIN's hash is reversed offline, and a hash cannot be searched for inside text). [`docs/secrets-vault.md`](/security-and-compliance/secrets-vault.md) no longer claims the ledger redacts both forms "for a resume"; it says what a resumed task reintroduces — from a captured property or a tool result — is not redacted.
* **Chat UI `isSafeUri`** resolves a slash-prefixed value against the page before calling it same-origin: `/\evil.example` is a path to another host in an http(s) URL. **`useTheme`** keeps the mode in force in a ref, so with blocked storage `setTheme("system")` keeps following OS changes instead of falling back to `initial`.
* **Chat UI `stepsToMessages`** trusts a turn output's `<secret input>` marker only when steps and outputs have the same length, as the backend does. A drifted snapshot used to pair an index with another turn's output and mask an ordinary message on undo/redo (a display error, not a disclosure). A step whose own `input:initial` reads `<secret input>` is still masked. Test: `snapshot.test.ts` "does not pair steps with outputs when a drifted snapshot's lists differ in length".

**Merge with main (2026-09-29)**

Main fixed the same problems independently while this branch was open (`fix/security-frontend`, `fix/security-access-control`). The merge keeps one implementation of each:

* **Turn-end scrub.** One pass, `Conversation.scrubSecretClientInput`; main's `scrubSecretUserInput` and its helpers are gone. Main's guarantees are kept: the raw input is remembered from the start of the turn, so its copies are still found after a `scope:"secret"` property has already replaced `input:initial`.
* **Decision: captured conversation properties are kept** (this branch's policy). Main scrubbed every conversation property of the input; that would turn the reference wizard's `apiKey`, captured from a password field with `{memory.current.input}` and sent to `create_agent` on a later turn, into the literal `<secret input>`. `ConversationSecretInputTest.secretInput_renderedCopiesScrubbed` now asserts the property is kept; the `secret` scope vaults it.
* **Decision: secrets shorter than 4 characters are removed as whole tokens** (main's policy). They are removed only where no letter or digit directly precedes or follows them, in step data other than properties, in outputs other than `input` and `properties`, and in the pending tool-call batch (new `SecretValueScrubber.scrubTypedTokens`). From 4 characters the substring search is unchanged. `ConversationSecretClientInputTest.shortSecretIsScrubbedFromEchoes` now expects "ok abc" to become "ok ". The audit ledger and the read-time masking keep the 4-character substring search only.
* **`SecretValueScrubber.scrubDeepTokens` no longer renames map keys.** It replaced a short secret in keys as well, so a PIN "id" would have renamed every `id` field of the turn's stored API responses. Only values change in token mode now; substring mode (4+ characters) is unchanged. Test: `SecretValueScrubberTest.wholeTokensLeaveMapKeysAlone` (fails with the change reverted).
* **Detailed reads now redact credential-named properties.** Main's `returnDetailed` redaction (denylisted keys, the secret redaction filter, everything under a credential-named key) applies on top of the secret-turn masking. A secret turn is masked first, then redacted, so a detailed read shows `properties.apiKey` and `properties:apiKey` as `<REDACTED>`; the non-detailed read and `conversationProperties` are unchanged.
* **`PropertySetterTask`.** One guard, `isScrubbedPlaceholder(name, value, scope)`: a value that is exactly `<secret input>` is not stored, in any scope, and for `scope:"secret"` a value that contains either placeholder is not vaulted (main's rule). The property is left unset.
* **Second merge (after `fix/memory-audit-findings`).** Main now wraps every stored property in `withInstructionVisibility`; the `fromObjectPath` string capture keeps that and this branch's placeholder guard. Main's new `properties:changes` undo log is a `properties:*` datum, so, like the property mirrors, it keeps a captured input — undo restores the value the designer captured. `ConversationSecretInputTest.secretInput_renderedCopiesScrubbed` skips `properties:*` data accordingly.
* **Read-time masking uses the same short-secret rule.** Masking an older stored secret turn on read used to drop forms shorter than 4 characters, so an echoed 3-digit PIN stayed visible there while the turn-end scrub removed it. It now removes them as whole tokens in values, never in map keys, like the turn-end scrub. Test: `ConversationMemoryUtilitiesTest.oldStoredShortSecretIsMaskedAsWholeTokenOnRead` (fails with the old cutoff restored).
* **Chat UI.** `?token=` is stripped once through the router (this branch); main's `replaceState` helper `stripTokenFromUrl` is removed, because it re-ran on the stripped URL and reset the token. Main's allow-list `apiServer` sanitizer and postMessage token handshake are kept; its control-character regex carries an ESLint exemption. `snapshot.ts` keeps one `SECRET_INPUT_PLACEHOLDER`.

### Refuted / not changed

* **"A double-clicked restart starts two conversations."** Refuted as stated. Every restart control unmounts synchronously on the first click (`CLEAR_MESSAGES` clears the `conversationId` the action bar and banners depend on), so no second click can reach one. The related real race, a restart during the first load's in-flight read, is fixed by the generation checks above.
* **Managed restart racing the first load (review).** Not reachable from the UI: every restart control needs the `conversationId` or state that the first managed load sets only when it completes, so no restart can start while it is in flight.
* Designer-configured images from external hosts are still blocked by EDDI's own `img-src 'self' data:` CSP when EDDI serves the widget. That is a deployment choice and is left alone.
* The descriptor endpoint is editor-only, so an end user with only `eddi-user` still sees the configured `title` instead of the agent name. A user-readable name endpoint would be a backend feature and is a follow-up.

***

## 🐛 fix(engine): conversation turn state — queued turns, pauses, ENDED, redo, groups, NATS, migrations (2026-09-26)

**Repo:** EDDI (`fix/conversation-turn-state`)

Eleven findings from the 2026-09-25 review, all about a conversation turn ending up in a state it should not have. Each has a regression test that fails without the fix.

### What changed and why

* **H13a / E1 — a queued turn ran on stale memory.** `say()` loaded the memory at request time and queued the turn behind the same conversation's running turns. When it finally ran, its `lastStep` rules, LLM history and property writes were computed without the previous turn, and the append merge re-applied its stale conversation properties over the ones that turn wrote (last writer wins). `ConversationStepRunner` now reads the stored revision (a new projection read, `IConversationMemoryStore.getRevision`) before running a queued turn. If the document has moved on, it reloads the memory and rebuilds the turn over it. The rebuild goes through a `TurnBuilder` that `say()`/`sayStreaming()` hand to the runner in place of a finished callable, because the `Conversation` captures its longTerm baseline from the memory it is built over. A turn that cannot be rebuilt (the agent went away meanwhile) is reported as skipped, not run on the stale snapshot. A queued **rerun** is never rebuilt: it re-executes the step the caller saw, and rebuilt over a newer memory it would re-execute another turn's step (its tool calls and LLM spend included), so a superseded rerun is skipped. The REST `say` endpoint now answers a skip by the state it happened in: ENDED → 410, IN\_PROGRESS → 409 "retry shortly", and an idle conversation (superseded rerun, failed rebuild) → 409 "the conversation changed while your message was queued — reload it before sending again" instead of the misleading "retry shortly". `IConversationMemoryStore.getRevision` is abstract, so a future store cannot silently disable the check. Residual: two turns of one conversation running concurrently on *different* pods still merge with last-writer-wins on the top-level fields, which is what the append path already documents.
* **E2 — a lost pause stayed in the state cache.** The say path caches the state a turn ended in from inside the pipeline, before the persist. When the pause commit was then refused (a revision or state CAS miss), the cache kept AWAITING\_HUMAN for a conversation the store held as READY, and `/resume` failed. Every refused or discarded outcome now re-reads the persisted state into the cache. (H13a also removes the most common cause, a queued turn built on an old revision.)
* **State cache expiry.** The `conversationState` cache never expired, and several writers never touch it (other pods, the HITL timeout and crash-recovery paths). Entries now expire 30 s after they are written (`ConversationService.CONVERSATION_STATE_CACHE_TTL`).
* **E4 — a full-document write overwrote ENDED.** Ending a conversation is a narrow state write that does not move `_rev`, so a turn already running (a rerun, or a memory whose append baseline is unknown) still passed the revision guard, and its replace resurrected the conversation. It happens on one pod, because `endConversation` runs on the REST thread. Both stores' full replace now refuses a stored ENDED unless the write itself is ENDED. The runner reports that refusal as "the end stands" at INFO, and it does not count as a store conflict.
* **M-E1 — lazy redeploy before the ENDED check.** `say()` called `getAgent` (which deploys an undeployed agent, without the tenant gate or a deployment-store write) before it found out the conversation had ended. The ENDED check now comes first.
* **M-E2 / M-E3 — superseded by main.** This branch carried a recalled group memory's `groupIds` on `Property` and cleared the redo cache on a new turn. Main landed both fixes independently (group scoping via `ConversationGroups` at the persistence boundary; the redo clear in `ConversationMemory.startNextStep()`), so the 2026-09-29 reconciliation below keeps main's and drops this branch's.
* **Step-scoped properties now clear on every exit.** They were dropped only from the clean-exit post-tasks. After an ERROR, cancel or abandon they reached the persisted snapshot and the next turn's `{properties.x}`. They are now dropped in the turn's `finally` on every exit except a HITL pause. The resumed step still needs them there, and they are dropped when it ends.
* **M-E4 — NATS coordinator.** (1) A queued turn that `submitNext` could not schedule is now told through `IDiscardableTask.onDiscarded`, as the in-memory coordinator already did. Before, the REST caller waited for its 408 and an SSE stream hung. (2) `routeToDeadLetter` and the ordering publish also catch `RuntimeException`. An `IllegalStateException` from a closed connection escaped `submitNext` and wedged the conversation's queue. A failed marker publish now degrades to local execution. (3) The conversation stream, whose messages nothing consumes, is bounded by age, count and bytes, and discards the oldest first: `eddi.nats.stream-max-age` (1h), `eddi.nats.stream-max-messages` (100000) and `eddi.nats.stream-max-bytes` (256 MiB).
* **16 MB — persistent heartbeats grew without bound.** A `conversationStrategy: persistent` schedule appended a step on every fire to one document until MongoDB refused it, and from then on every fire was lost. The rollover is **opt-in**: with `eddi.schedule.persistent-conversation-max-steps` set (default `0`, off), a conversation that has reached that many steps AND is idle (READY, ERROR, EXECUTION\_INTERRUPTED) is ended and replaced by a new one. The `conversation`-scoped properties are copied into the new conversation (what its own start turn set wins). `longTerm` properties need nothing, they live in user memory. The **LLM history does not carry over**. A conversation that is AWAITING\_HUMAN or busy is never rolled over: that would end the pending approval (audited as a scheduler decision), abort a running resume or refuse a human's in-flight turn. When enabled, every persistent schedule already past the limit rolls over on its first idle fire. While the rollover is off, a persistent conversation past 1000 steps is reported once at WARN. Nothing is trimmed and the old conversation stays readable. A persistent conversation that has ENDED is replaced now as well; every fire into it used to be refused, forever. The fire reads the stored conversation once, raw, rather than converting every step into a response snapshot. Only a conversation the store reports missing (or an id it cannot parse) or one of another agent counts as gone: any other load failure now fails the fire, which is retried against the same conversation, instead of silently repointing the schedule at a fresh one. A rollover whose end call fails keeps the old conversation until a later idle fire, and properties are carried only when the old conversation belongs to the schedule's current user. See [scheduling.md](/conversations-and-orchestration/scheduling.md#long-running-persistent-schedules).
* **E3 — migrations marked complete on empty collections.** The V6 Qute, channel connector and workspace access-index migrations read the collections the V6 rename migration populates. If the rename migration was still pending, they found nothing, recorded themselves complete, and never ran again. They are now parked until it completes, and they are not flagged. The first deployment sweep that sees the rename complete runs them, before it deploys anything or reports ready — so a migration-log read that failed transiently at boot no longer defers them to the next restart.
* **E6 — the deployment dedupe deleted the live row.** `replaceOne` rewrites the first matching row, which is the oldest. The dedupe kept the newest, so it deleted the row every deploy/undeploy had written to. `setDeploymentInfo` now stamps `lastModified`. The dedupe keeps the most recently stamped row, and among rows written before the stamp existed, the lowest `_id`, i.e. the one `replaceOne` was rewriting. That order only breaks ties: when the rows disagree on their status and there is no strictly newest stamp, the dedupe keeps the row the point read (`find(filter).first()`) returns — the status the store already reports — and if that cannot be read it keeps every row of the group rather than guess.

### Compatibility

* Stored data: deployment rows gain `lastModified` (ignored by the reader). Additive; no migration is needed.
* REST/MCP shapes: unchanged. The REST `say` endpoint's answer to a skipped turn now depends on the state (410 for ENDED, a different 409 text for an idle conversation that changed under the queued message).
* New config keys: `eddi.nats.stream-max-age`, `eddi.nats.stream-max-messages`, `eddi.nats.stream-max-bytes`, `eddi.schedule.persistent-conversation-max-steps`. They are documented in [configuration-reference.md](/deployment-and-infrastructure/configuration-reference.md).

**Files:** [`ConversationStepRunner.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java), [`ConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/ConversationService.java), [`IConversationMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/IConversationMemoryStore.java), [`ConversationMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryStore.java), [`PostgresConversationMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/postgres/PostgresConversationMemoryStore.java), [`Conversation.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/Conversation.java), `NatsConversationCoordinator.java` (since removed), [`ScheduleFireExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java), [`AgentDeploymentManagement.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java), [`MongoDeploymentStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/deployment/mongo/MongoDeploymentStorage.java)

### Not done here

* Conversations that grow large *without* a schedule (a very long chat) still have no document-size guard. That needs the step-archival design the 2026-06 notes already call for, not a quick cap.
* Cross-pod concurrent turns of one conversation still merge top-level fields last-writer-wins (see H13a above).

### Reconciled with main (2026-09-29)

Main landed parallel work in the same area while this PR was open. Where both sides fixed the same thing, main's implementation is kept and this branch's is dropped:

* **M-E2 (group memories)** — kept main's `ConversationGroups.resolveGroupIds` / `persistedVisibility` and `UserMemoryEntry.fromProperty(…, visibility, groupIds)` (bfa438310, bc4908873): the turn's verified group is stamped at the persistence boundary and a group property outside any group is stored as `self`. Dropped this branch's `Property.groupIds` carry (and its `PropertyInstruction` / `MemoryCheckpoint` plumbing, the baseline-adoption helpers in `Conversation` and their tests), which contradicts main's narrowing rule.
* **M-E3 (redo after a new turn)** — identical fix on main (2984e4e15, 7011bceba); kept main's Javadoc and `ConversationMemoryUndoPropertiesTest`, dropped this branch's duplicate tests.
* **Step-scoped properties** — still missing on main (a failing turn persisted them; proven by `stepScopedPropertyIsDroppedWhenTheTurnFails` against main's code). Ported onto main's `Conversation`: the turn's `finally` blocks drop them on every exit but a HITL pause; `postConversationLifecycleTasks` keeps main's `recordPropertyChanges`, which still runs after the cleanup.
* **Persistent schedules** — main's fire-path ownership guard (8d24ae335) is carried onto this branch's raw-load resolver: a persistent conversation owned by another user is neither reused nor rolled over, and a fresh one is started. New tests `fire_persistentStrategy_refusesAConversationOwnedByAnotherUser` and `…_reusesAnUnownedConversation`.
* Every other item (H13a, E2, state-cache TTL, E4, M-E1, NATS, the rollover, E3, E6) had no counterpart on main and is unchanged; `createPropertiesHandler` calls in the turn builders take main's `isMemoryToolsEnabled()` argument.

### Review follow-up (2026-09-29)

* **E6: the deployment dedupe can no longer delete every row for a key.** It deleted the rows it dropped by id alone. Two nodes deduplicating at the same time, with a deploy landing between them, could each keep a different row and delete the other's. Each delete now applies only while the row still holds the `lastModified` and `deploymentStatus` the dedupe observed, so a rewritten row survives. A group whose kept row changed after it was observed is left for the next pass. No transaction is used, so a standalone server keeps working.
* **E3: callers wait for the deferred migrations to finish before deploying.** The flag was cleared when the migrations started. The startup callback and the scheduled sweep can run `checkDeployments` at the same time, so the second one skipped the migrations and deployed from documents still being migrated. The run now holds a lock and clears the flag only when it finishes. Every caller waits for it before it deploys or reports ready.

***

## 🔒 fix(deploy): no shipped deployment logs straight in or reaches a database from any pod (2026-09-26)

**Repo:** EDDI (`fix/deployment-defaults`) — fix-plan item 16

### What changed and why

Every delivery path — compose, the installers, the Kustomize overlays, the Helm chart — shipped at least one credential that worked on a fresh install, and the in-cluster datastores trusted every pod in the cluster.

**Identity (C5a, C5b, NEW dead `eddi`/`eddi`).**

* The realm (all three copies) seeded `viewer`/`viewer` and `user`/`user`, which logged straight in (`temporary: true` is ignored on import), and the public `eddi-frontend` client allowed the password grant — one `curl` returned a token that could run LLM turns. No account ships a credential now, and the grant is off on `eddi-frontend`. The Manager uses the code flow and never needed it. The auth E2E tier (`make-test-realm.mjs`) supplies every fixture password and turns the grant back on in its generated realm only, and fails if the shipped realm regresses.
* `docker-compose.auth.yml` hard-coded the Keycloak bootstrap admin as `admin`/`admin` on `0.0.0.0`. It now publishes on `127.0.0.1` (`KEYCLOAK_BIND` to change it). *(This branch also made the password a required `KEYCLOAK_ADMIN_PASSWORD`; at reconciliation main's `KC_BOOTSTRAP_ADMIN_*` with a loopback-only `admin` default was kept — see the entry below.)*
* `install.sh` / `install.ps1` generate the admin password and keep it in `.env`. They rotate a legacy `admin`/`admin` login to it on re-run, and give `eddi` a one-time password (Keycloak forces a change at first login) instead of printing the `eddi / eddi` the realm stopped seeding. `--demo-users` / `-DemoUsers` does the same for `viewer` and `user`. On every path — fresh, re-run on a stopped stack, or re-run while EDDI is running — both installers also turn the password grant off on existing realms. `gcp/provision-vm.sh` reads the generated password, stops opening 8180/3000/9090 to the internet, and prints SSH tunnels instead. Both installers' Admin-API code was run against a real Keycloak 26.0.8: a fresh realm import, the legacy rotation, the first-login password (verified `requiredActions: ["UPDATE_PASSWORD"]`), idempotence on re-run, and `unauthorized_client` for the password grant. The PowerShell run found that PS 7's `Invoke-RestMethod` emits a JSON array as one object, so an empty credentials list counted 1; that is fixed.

**Datastores (C5c, C5d).**

* Helm (chart **3.0.0**; *superseded on main by #865 before merge — see the reconciliation entry below*): the in-chart MongoDB authenticates. `mongodb.auth.password` is required. The credentialed connection string reaches EDDI as a mounted file from the `<release>-mongodb` Secret. The ConfigMap's credential-less entry now renders only for the explicit `mongodb.auth.enabled=false`: a file listed in `QUARKUS_CONFIG_LOCATIONS` inherits that env var's ordinal (300, confirmed in SmallRye's `AbstractLocationConfigSourceFactory`), so the two would tie. `networkPolicy.datastores.enabled` (default on) admits only the EDDI pod to MongoDB, PostgreSQL and NATS.
* Kustomize: `overlays/mongodb` authenticates from an operator-created `mongodb-secrets`, and removes the base's credential-less connection string. `overlays/mongodb` and `overlays/postgres` each ship an EDDI-only NetworkPolicy. `postgres-secret.yaml` (committed `eddi`/`eddi`, applied by `postgres-ha`) became `postgres-secret.yaml.example`.
* `quickstart.yaml` keeps its unauthenticated evaluation MongoDB, so the one-file contract holds, but gains the same policy and a clear banner.
* Compose dev overlays (NATS, Chroma, Ollama, Prometheus, Grafana, Jaeger/OTLP) publish on `127.0.0.1`. Grafana's admin password needs a `grafana-admin` Secret (k8s); in compose `GRAFANA_ADMIN_PASSWORD` keeps main's `admin` default and the installers generate it (see the reconciliation entry below).

**Cluster hygiene (NEW, L-F2).** Prometheus gets a namespaced Role instead of a ClusterRole: it only ever discovers its own namespace. No pod except Prometheus mounts a service-account token (Helm: `serviceAccount.automountToken`, default false).

**Browser policy (M-F3, M-F4).**

* `/chat` has its own CSP filter, with `frame-ancestors` taken from `eddi.chat.frame-ancestors` (default `'none'`). `X-Frame-Options: DENY` moved from a global header onto the default and Swagger filters, so the documented iframe embed can be enabled without opening the Manager or the API to framing.
* `img-src` allows `blob:`, so staged attachment previews render.
* `connect-src` appends `eddi.csp.extra-connect-sources` (empty by default) for the wizard's fetch-spec-by-URL.

**Metrics (M-F5).** `/q/metrics` has an explicit rule driven by `eddi.metrics.http-policy` (default `authenticated`; Helm `eddi.metrics.httpPolicy`). The monitoring guide's new "Scraping with authentication on" section documents the oauth2 client-credentials scrape. Both scrape configs carry it commented, and the installers warn when auth and monitoring are combined.

**Docs and messages (A4, A6, A7, B1).**

* A4: why the relative RFC 9728 resource should be absolute in production; Helm gets `eddi.oidc.resourceMetadata.resource`.
* A6: the `offline_access` advice was checked against Keycloak — the scope and role exist after import, but clients request only `scopes_supported`. The doc now gives the two ways that work.
* A7: `AuthStartupGuard` named the workspaces claim as quarkus-oidc's default claim. It now names `groups`, and reports a collision only when there is one.
* B1: `Dockerfile.demo` copies `ui/`, so `mvn package` can build the UIs the demo relies on.

### What this breaks — upgrade paths

| Change                                                                                                                                                                                                                  | Who is affected                                                                                 | Upgrade                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Realm fixtures have no password; `eddi-frontend` refuses the password grant                                                                                                                                             | New imports only (import is one-shot)                                                           | Set passwords in the console, or re-run the installer (`--demo-users`). Existing realms keep `viewer`/`viewer` and `user`/`user` until changed; without `--demo-users` the installers leave them alone but **warn** when either still has a credential. Both installers close the grant on every re-run path. A script that used the password grant on `eddi-frontend` must switch to code flow, or re-enable it deliberately.                                               |
| Installers generate the Keycloak and Grafana admin passwords and record them in `.env` (`KC_BOOTSTRAP_ADMIN_PASSWORD`, `GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD`); compose keeps its overridable `admin` defaults | Installer users whose `keycloak-data` / `grafana-data` volume predates the generated password   | Re-run the installer. It moves a service that still accepts `admin`/`admin` to the generated password and only then records it in `.env`. A service with a password of its own is left alone with a warning naming the `.env` line to add — except a generated Grafana password on a fresh or stopped stack, which stops the install before the success banner. A plain `docker compose up` needs no setting, and `eddi update` has no pre-flight check for these variables. |
| Infrastructure ports bind `127.0.0.1`                                                                                                                                                                                   | Anyone reaching Keycloak/Grafana/Prometheus/NATS/Chroma/Ollama from another machine             | SSH tunnel or reverse proxy. `KEYCLOAK_BIND=0.0.0.0` for Keycloak, behind TLS.                                                                                                                                                                                                                                                                                                                                                                                               |
| In-chart MongoDB authenticates (main's `mongodb.rootPassword`, required when `mongodb.enabled`)                                                                                                                         | `helm upgrade` of a release whose MongoDB volume predates authentication                        | Create the user in the running database first, then upgrade with the same `mongodb.rootPassword` and `mongodb.authMigrated=true` — see [Upgrading to an authenticated MongoDB](/deployment-and-infrastructure/kubernetes.md#upgrading-to-an-authenticated-mongodb). A live upgrade that skips this refuses to render.                                                                                                                                                        |
| Helm datastore NetworkPolicies (main's `templates/networkpolicy.yaml`) render only with `networkPolicy.enabled=true`; the default is `false`                                                                            | Operators who turn it on while other workloads (backup jobs) connect to the in-chart datastores | Add a policy admitting those workloads. With the default nothing changes, which leaves the in-chart NATS reachable from every pod (see Known gap below).                                                                                                                                                                                                                                                                                                                     |
| Kustomize `overlays/mongodb` authenticates from `mongodb-secrets`                                                                                                                                                       | Existing installs re-applying the overlay                                                       | Create the user in the running pod first, then the Secret, then apply. The overlay header has the commands; the localhost exception lets you recover if applied first.                                                                                                                                                                                                                                                                                                       |
| `postgres-secret.yaml` no longer a resource                                                                                                                                                                             | New installs                                                                                    | Create `postgres-secrets` from the `.example`. Existing installs keep theirs, since `apply -k` does not prune.                                                                                                                                                                                                                                                                                                                                                               |
| Grafana `grafana-admin` Secret required (k8s)                                                                                                                                                                           | Monitoring component users                                                                      | `kubectl create secret generic grafana-admin …` (header + docs).                                                                                                                                                                                                                                                                                                                                                                                                             |
| Prometheus Role instead of ClusterRole                                                                                                                                                                                  | Monitoring component users                                                                      | Delete the leftover `ClusterRole`/`ClusterRoleBinding` `eddi-prometheus`.                                                                                                                                                                                                                                                                                                                                                                                                    |
| No service-account token in pods                                                                                                                                                                                        | Anything added to these pods that calls the API                                                 | Helm `serviceAccount.automountToken=true`, or a manifest edit.                                                                                                                                                                                                                                                                                                                                                                                                               |
| `/chat` CSP split; `X-Frame-Options` no longer global                                                                                                                                                                   | Nobody by default (`/chat` still `'none'`)                                                      | Set `eddi.chat.frame-ancestors` to embed.                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| GCP script opens no 8180/3000/9090 firewall rules                                                                                                                                                                       | Existing VMs keep their old rules                                                               | Delete `allow-eddi-keycloak` / `allow-eddi-grafana`.                                                                                                                                                                                                                                                                                                                                                                                                                         |

### Review follow-up (same day)

* **Upgrade command:** the MongoDB command in values.yaml had lost its line continuation. As copied it ran a bare `mongosh admin`, exited 0 and created no user. It also named `<release>-eddi-mongodb`, which no release creates. It is now a multi-line sequence against `<fullname>-mongodb`. The password is generated once, with `printf`, because `--set-file` keeps a trailing newline as part of the value. The sequence was extracted, checked with `bash -n`, and run against stub `kubectl`/`helm`. `helmMongoUpgradeCommandIsWellFormed` pins it.
* **`--reuse-values`:** the new switches were read as `(… | default dict).enabled`, so a missing key — exactly what `helm upgrade --reuse-values` from 2.x produces — meant off. The upgrade exited 0 with MongoDB still open and no datastore policy. Both switches now go through `eddi.enabledUnlessFalse`, which turns a switch off only on an explicit false. Verified by rendering the 3.0.0 templates with the 2.1.0 values.yaml: refused. CI gained two `expect_failure` cases and a positive `networkPolicy.datastores=null` render. `helmSecuritySwitchesDefaultOnWhenAbsent` pins the wiring.
* **Installers:**
  * The admin passwords are now carried forward in `.env` whatever the flags, and `.env` is written under `umask 077`.
  * A legacy `.env` on the already-running path is handled explicitly (rotate then record, or stop with guidance). Both installers close the password grant on every path and warn about legacy `viewer`/`user` credentials.
  * Without a TTY, the one-time password goes to a 0600 `first-login.txt` instead of the log. The GCP script points there.
  * Checked against Keycloak 26.0.8 (bash on the jq and python3 paths, and pwsh 7.4.6), with the realm made legacy on purpose.
* **Docs:** Helm installs generate `eddi-secrets.yaml` once, behind an `[ -e ]` guard, and reuse it for every upgrade. The Kustomize `mongodb-secrets` snippets skip creation when the Secret exists and warn against re-running.
* **HTTP-level CSP check:** `InfrastructureIT.chatAndManagerFramingHeaders` asserts that `/chat` gets one CSP header and no `X-Frame-Options`, and that `/manage` and `/q/health/ready` keep `DENY`. It runs in CI's Integration Tests job; it was not run locally.
* **Nits:**
  * The "ordinal 400" text is corrected to 300 (a tie) in the chart, the CI comment and the test.
  * The datastore NetworkPolicy comment now says that NATS probes are HTTP on 8222. They are deliberately not admitted: the NetworkPolicy spec lets node-originated kubelet probes through, so the probes pass while other pods still cannot read the endpoint.
  * The monitoring stack names `EDDI_METRICS_HTTP_POLICY`.
  * `ui/manager/docker-compose.keycloak.yml` requires `KEYCLOAK_ADMIN_PASSWORD`. The CI-only `docker-compose.integration-keycloak.yml` keeps `admin`/`admin` as a documented test-tier exception.

### Second review follow-up (same day)

* **Grafana credential on the fresh / stopped-stack path:** Grafana reads `GF_SECURITY_ADMIN_PASSWORD` only when it creates its database. So a `grafana-data` volume from an earlier install keeps its own admin password when `.env` has none and the installer generates one. The installers probed `admin`/`admin`, returned silently when it was refused, and printed a success banner pointing at a password Grafana rejects. Now `confirm_grafana_login` (`install.sh`) and `Confirm-GrafanaLogin` (`install.ps1`) do four things. They wait for Grafana's `/api/health`, since EDDI's readiness says nothing about Grafana's. They rotate a legacy `admin`/`admin` login as before, and confirm that Grafana accepts `GRAFANA_ADMIN_PASSWORD`. If it accepts neither, they **stop** when this run generated the value, with the `.env` line to fix. The stack is left running, so a re-run takes the already-running path. A value that came from `.env` or the environment gets a warning instead: the operator may have changed it in Grafana since. A Grafana that never answers is reported as unverified. `install.sh` sends the Basic credential to curl as a config file on stdin, not on the command line.
* **Checked, not changed:** the review's other finding, that `.env` is written before `resolve_stack_passwords` generates the Grafana password, does not hold. `.env` is written in `resolve_compose_files`, which `main()` calls after `resolve_stack_passwords`. `install.ps1` has the same order (`Resolve-StackCredential`, then `Get-ComposeFiles`). There is no `write_env`; a comment that named one now names `resolve_compose_files`.
* **Tests:** `DeploymentManifestsTest.InstallerGrafanaCredential` pins both installers' order: passwords resolved, then `.env` written, then Compose started. It also pins that `install.ps1` confirms the Grafana login between `Wait-ForReady` and `Write-Success`. It runs `install.sh`'s two Grafana functions against a stand-in `curl` that plays a Grafana: a matching password, a legacy `admin`/`admin` volume (rotated), a foreign password with a generated value (stops) and with a stored one (warns). It also asserts that no password reached curl's argv. All three tests failed with the fix reverted. The PowerShell side is checked structurally only: no `pwsh` was available locally, and `Invoke-RestMethod` cannot be stubbed from PATH.

### Third review follow-up (same day)

* **A failed `admin`/`admin` change no longer counts as confirmation.** In `install.sh`, a refused change only warned and returned. In `install.ps1`, a still-valid `admin`/`admin` login returned before `GRAFANA_ADMIN_PASSWORD` was tested. Either way the install reached the success banner with a password Grafana rejects. Both installers now return early only when the change succeeded. Otherwise they check the value in `.env`, since a change reported as failed may still have applied. When it is refused, they apply the same rule as before: a password generated this run stops the install, and an operator-supplied one only warns. The stop message says how to recover. EDDI stays running, and `.env` keeps the generated value, so the operator can re-run the installer or set the password in Grafana's profile page.
* **The re-run retries.** The already-running path used to check Grafana only when `.env` had no password. It now also checks a stored one, with a mismatch warning only, so the re-run that message asks for attempts the change again. The Grafana port is read from `.env` for that check.
* **No jq or python3:** `install.sh` builds the change request by hand when the password is alphanumeric, which every generated one is. So a host without either tool can still move `admin`/`admin` to the generated value instead of hitting the stop.
* **Tests:** `shellConfirmsTheGrafanaLogin` gains three runs against the stand-in `curl`:

  * a refused change with a generated password (stops, and names the re-run);
  * a refused change with a stored password (warns);
  * a rotation with no JSON tool.

  Both installers are now checked for calling the Grafana check on the already-running path. `install.ps1` is checked for returning only on a successful change, and for stopping on both refusals when the password was generated. Five mutations, each reverting one of these, each failed a test.

### Deferred / partial

* M-F3's second half: the Chat UI never strips `?token=` from the URL. That is a `ui/chat` change, left to the UI branches.
* M-F4: the wizard's fetch-spec-by-URL still needs `eddi.csp.extra-connect-sources` set; the default stays strict, and File/Paste work. A friendlier UI message is a `ui/manager` follow-up.
* M-F5: the installers do not create a Keycloak client for Prometheus. They document the scrape and warn.
* C5c: `quickstart.yaml`'s MongoDB is isolated but still unauthenticated, to keep the one-command evaluation contract.
* The Windows `eddi.cmd update` has no pre-flight check for the new admin passwords; compose's own error names the missing variable.

**Files:** realms, [`docker-compose.auth.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.auth.yml), [`docker-compose.monitoring.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.monitoring.yml), [`install.sh`](https://github.com/labsai/EDDI/tree/main/install.sh), [`install.ps1`](https://github.com/labsai/EDDI/tree/main/install.ps1), [`helm/eddi/`](https://github.com/labsai/EDDI/tree/main/helm/eddi/Chart.yaml), `k8s/overlays/{mongodb,postgres,monitoring}`, [`application.properties`](https://github.com/labsai/EDDI/tree/main/src/main/resources/application.properties), [`AuthStartupGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/AuthStartupGuard.java), [`Dockerfile.demo`](https://github.com/labsai/EDDI/tree/main/src/main/docker/Dockerfile.demo); tests in `DeploymentManifestsTest`, `ComposeStackTest`, `CspPolicyTest`, `MetricsHttpPolicyConfigTest`, `AuthStartupGuardTest`, `DemoImageDockerfileTest`.

***

## 🐛 fix(groups): group-conversation state races, deleted discussions coming back, workspace lost updates (2026-09-26)

**Repo:** EDDI (`fix/group-conversation-state`)

### What changed and why

**H14a — a cross-pod cancel was undone by the running leg.** Group control is per-pod: the cancel token lives in the process that runs the leg, so a cancel landing on another pod can only CAS the persisted state `IN_PROGRESS → CANCELLED` and report success. The running pod's next whole-document write (`conversationStore.update(gc)`, an upsert) wrote `IN_PROGRESS` back, and its completion CAS then committed `COMPLETED`. The phase-boundary re-read narrowed the window without closing it, and ten writes were unconditional: the phase-boundary persist, the four cancel writes (top of the phase loop, before the HITL gate, and the two cancel branches of the catch blocks), the facilitator phase-list persist, and the four pause / no-progress commits in `GroupHitlCoordinator`. Every one of them now goes through `RunningDiscussionWrites.updateWhileRunning`: a compare-and-swap on the persisted state still being `IN_PROGRESS` or `SYNTHESIZING` (a leg flips to `SYNTHESIZING` in memory before it persists the flip, so both are tried). A lost CAS raises `DiscussionSupersededException`; `executeDiscussion` catches it, adopts the persisted outcome, aligns the in-memory state so the `finally` makes the right ephemeral-agent decision, and always sends the listener a terminal event so a streaming client's sink closes: `cancelled` for CANCELLED, `group_complete` for COMPLETED/CLOSED, a curated `group_error` for anything else (FAILED, REJECTED, a pause committed elsewhere). The completion CAS uses the same helper, which also fixes a completion that failed when the in-memory `SYNTHESIZING` had not been persisted yet.

**H14b — deleting a running discussion let it come back.** Two parts. (1) `GroupConversationStore.update` is now a replace of an existing row only (`storeIfCurrentVersion(SINGLE_VERSION)`), raising `GroupConversationGoneException` when the row is gone — **the same change, byte for byte, as `fix/gdpr-erasure`**, so the merge is trivial; the `stopAsDeleted` / `isDeletedWhileRunning` / `persistCancelled` helpers and the catch blocks are ported from that branch verbatim (its `activeTokens` → `discussionControls` rename too); only `persistCancelled` differs, because here it writes conditionally. (2) `GroupLifecycleOps.deleteGroupConversation` now signals a leg running on this node (`CANCEL_IMMEDIATE`) before it ends the members and deletes the ephemeral agents — the first `discuss()` leg is not an "operation in progress", so the existing guard never saw it. A leg on another node has no token here. It finds out one of two ways, and both end it as a cancel, not a failure (no ERROR log, no failure metric, no 5xx): its next conditional write finds the document gone, or — because the deleting node also ended the member conversations and deleted the ephemeral agents — a member turn fails first, and before treating that failure as the discussion's the catch blocks re-read the document (`stopIfEndedElsewhere`): gone means deleted, a non-running state means superseded.

**H14c — the workspace store had three write schemes that ignored each other.** `casRevision` compared the revision, `casRunningDiscussion` compared the run claim, and cadence add/delete wrote unconditionally — each a whole-document replace. A backlog add read before a cadence claim passed its revision check after it and wrote the claim away (run orphaned, pulled tasks back to PENDING and pulled again); a claim wrote back a backlog missing a concurrently added task. Now there is one scheme: every write compares and bumps the revision. (`casRunningDiscussion` no longer takes the expected claim, which an unchanged revision already implies.) Every release that shipped workspaces (6.3.0 on) persists the `revision` field, but a stored document *without* it — hand-restored, or from a pre-release build — would otherwise never be writable: it reads back as the `"0"` default, and on both backends a strict `revision == "0"` never matches a missing field (MongoDB's equality filter; PostgreSQL's `data ->> 'revision'` is NULL). So the write at revision `"0"` uses a new storage primitive, `IResourceStorage.storeIfFieldEqualsOrMissing` (MongoDB `$or: [{revision: "0"}, {revision: null}]`; PostgreSQL `(… = ? OR … IS NULL)`), inside the same atomic write, and stamps `"1"`. It stays a CAS: of two writers racing on a revision-less document the first stamps the field and the second matches neither branch and loses. A higher revision keeps the strict comparison. `IGroupWorkspaceStore.update` is gone. Cadence add/delete are revision-checked with the same re-read-and-retry as backlog adds and answer **409** after `MAX_CAS_ATTEMPTS`. A lost add deletes the schedule it just created; a delete writes the workspace first and deletes the schedule only once that write has landed, so a 409 leaves cadence and schedule intact together. Because a revision guard also loses to unrelated edits, `TeamCadenceService` re-reads on a lost claim or settle: a claim is re-applied to the fresh document while it is still idle and every pulled task still executable; a settle is redone on the fresh document while it still names the discussion being settled. Both are bounded (`MAX_WRITE_ATTEMPTS = 3`).

**Cadence claim loss ran phase 0.** `startCadenceDiscussionAsync` created *and submitted* the discussion before the claim, so a lost claim could only cancel — gracefully — a leg that might already be inside phase 0. It is replaced by `prepareCadenceDiscussion`, returning a `CadenceDiscussion` handle: the conversation exists (so the claim carries its id), nothing runs until `launch()` after the claim is won, and a lost claim calls `abandon()` (CAS `IN_PROGRESS → CANCELLED` of a discussion that never ran, and drops its control token).

**Group prompt and record bounds (M-G1, M-G2, M-G3, M-G4, G3)** — the bounds are configurable, with the values below as defaults and hard ceilings no config can exceed (see "Config surface").

* *M-G1* `NegotiationEngine`: one BARGAIN turn records at most 5 concessions, each stored truncated to the quoting bound (600 chars); the ledger stops at 50 (earliest kept — they are the record the outcome quotes); a turn's prompt quotes only the newest 20 with "(N earlier concession(s) omitted)", which also bounds ledgers stored before the caps. Concessions past the ledger cap are counted, not only logged: `NegotiationState.droppedConcessions` (additive; a stored state without it reads 0) feeds "N further concession(s) not recorded (ledger full)" in the AGREEMENT outcome, `tally.concessionsNotRecorded`, and a line in the rendered ledger — so a capped ledger is never presented as the complete record.
* *M-G2* `VoteTallyEngine`: only what was cast counts. For a JSON ballot only the `vote`/`votes` values are read, never the free-text `statement`, which the old fallback scanned; an explicitly empty ballot (`"votes": []`, `"vote": null`, or no vote field) is a non-vote; a cast in a shape the method did not ask for still counts when it names exactly one option (`{"votes": ["X"]}` under MAJORITY, `"votes": "X"` under APPROVAL). Prose ballots and the tiebreak's `resolveChoice` match an option as a whole word/phrase, so "No" is not found inside "not"/"know" — but the boundary applies only between words of space-separated scripts, so Chinese/Japanese/Thai options still match by substring ("我支持方案A。" votes `方案A`).
* *M-G3* `PhaseExecutionEngine.setDecisionCarryingDissents`: earlier dissents are merged with the new decision's (earlier first, identical ones once) instead of being replaced whenever the new decision carried any.
* *M-G4* `RetroEngine`: a stored lesson value (lesson + "applies:" context) is bounded to 1000 chars — the default of an agent's `memoryGuardrails.maxValueLength`. The context is truncated first and dropped when too little room is left. The idempotency key hashes the **untruncated** lesson, so a lesson stored before the cap is recognised rather than stored a second time.
* *G3* `StanceSummaryEngine`: summarizer input is the member's newest contributions within 8,000 chars (2,000 per contribution), with "\[N earlier contribution(s) omitted]" — it used to be every contribution concatenated, re-sent at every boundary and eventually past the summarizer's context window.

**Timed-out PARALLEL members were anonymous.** When the batch deadline released a parallel speaker (or its turn errored), the orchestrator recorded a `SKIPPED`/`ERROR` entry for `"unknown"` and fired no `speaker_complete` for it — only successful entries did. The transcript could not say who timed out, and a client that showed the member typing on `speaker_start` kept showing it forever (the backend half of the Workforce "typing indicator forever" finding). Every outcome is now attributed to its speaker and closed with `speaker_complete`. `speaker_complete` gains an additive `outcome` field — `TIMEOUT`, `SKIPPED` or `ERROR`, `null` for a contribution — and a turn without a contribution carries no content (`response: null`; its reason stays in the transcript's `errorReason`, so no raw exception text goes over the wire), letting a client tell a timeout from something the member said.

**Workforce history owner filter ran after pagination.** `GET /groups/{groupId}/conversations` (and MCP `list_group_conversations`) fetched a page of everyone's conversations and then removed the ones a non-admin does not own, so non-admins got short or empty pages while their conversations sat on later ones. The owner restriction is now part of the store query (`IGroupConversationStore.listByGroupId(groupId, ownerUserId, index, limit)`, the userId escaped and anchored like the erasure sweep and re-checked exactly), so `index`/`limit` page through the caller's own conversations. The REST shape is unchanged; the UI needs no change to benefit.

### Deferred / notes

* *Timed-out member writes*: read as the attribution + `speaker_complete` defect above. A related gap stays open: with `onAgentFailure: RETRY`, a member whose attempt timed out is re-sent a turn while the first may still be running on the coordinator (the conversation queue serializes them), so the member can execute twice. Fixing it needs the member turn cancelled through the coordinator, which is its own change.
* Merges with `fix/gdpr-erasure` (#834): this branch carries that PR's rename and its lost-ownership helpers verbatim, so a merge leaves six small conflict hunks, five in `GroupConversationService`: (1) the cadence `launch()` block — keep this branch; (2, 3) the two catch blocks — keep this branch (it adds the `stopIfEndedElsewhere` re-read after the lines both branches share); (4) `persistCancelled` — keep this branch's conditional `persistWhileRunning`; (5) the `discussionControls` field — take #834's `erasureStepName` / `stopInFlightWork`. The sixth is one line in `GroupConversationServiceBranchCoverageTest`, where this branch carries #834's deleted-while-running tests with the Gone stub moved to `updateIfState` — keep this branch. `GroupLifecycleOps`, `GroupHitlCoordinator` and `GroupConversationStore` merge cleanly, and the resolved merge compiles (main and tests) with #834's erasure and in-flight tests passing.
* Review refutation: `speaker_complete` for a timed-out or failed parallel turn never carried raw exception text — the text sat in the transcript entry's `errorReason`, and the event carried the (null) `content`. The new `outcome` flag makes the distinction explicit.

### Config surface

All optional; absent means the defaults, and every value is clamped to its ceiling (non-positive = default):

```json
"negotiationConfig": { "maxConcessionsPerMove": 5, "maxLedgerConcessions": 50, "maxRenderedConcessions": 20 },
"retroConfig": { "maxLessonsPerRun": 3, "maxStoredLessons": 50, "maxLessonChars": 1000 },
"stanceSummary": { "maxChars": 160, "maxInputChars": 8000, "maxEntryChars": 2000 }
```

Ceilings: 20 / 500 / 100 for the negotiation caps, 4000 for `maxLessonChars`, 32000 / 8000 for the stance input caps. Documented in [`group-conversations.md`](/conversations-and-orchestration/group-conversations.md).

### Compatibility

* Stored group configs: new optional fields only; existing configs load unchanged and get the defaults.
* REST: `POST` and `DELETE /groupstore/groups/{id}/workspace/cadences…` can now answer **409** when the workspace is modified concurrently through three attempts (they could not conflict before — they overwrote). `GET /groups/{groupId}/conversations` pages differently for non-admins (their own conversations only, in the query).
* SSE: `speaker_complete` gains the additive `outcome` field and fires for timed-out parallel members.
* Internal API only: `IGroupWorkspaceStore.update` and `GroupConversationService.startCadenceDiscussionAsync` are removed; `casRunningDiscussion` takes one argument.

**Files:** [`RunningDiscussionWrites.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/RunningDiscussionWrites.java), [`GroupConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java), [`GroupHitlCoordinator.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java), [`GroupLifecycleOps.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java), [`GroupConversationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/mongo/GroupConversationStore.java), [`GroupWorkspaceStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/mongo/GroupWorkspaceStore.java), [`IResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java), [`MongoResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java), [`PostgresResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java), [`NegotiationEngine.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/NegotiationEngine.java), [`RestGroupWorkspace.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/rest/RestGroupWorkspace.java), [`TeamCadenceService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java)

**Tests:** `GroupConversationStateRaceTest` (a CAS fake store: a cancel landing between the boundary re-read and the write; a delete mid-run; delete signalling the leg; superseded to FAILED/CLOSED sends a terminal event; a member turn failing after a cross-node delete ends as a cancel), `RunningDiscussionWritesTest`, `GroupWorkspaceStoreTest`, `GroupWorkspaceStoreMongoContainerTest` / `GroupWorkspaceStorePostgresContainerTest` (Testcontainers: a revision-less document's first write lands, two racing first writes → one lands, a stale writer still loses at a stamped `"0"`), `MongoResourceStorageTest` / `PostgresResourceStorageTest` (the equals-or-missing filter and predicate), `RestGroupWorkspaceTest` (cadence 409s, schedule kept on a lost delete), `TeamCadenceServiceTest` (claim/settle retries on the fresh document, prepare → claim → launch order, abandon on a lost claim), `NegotiationEngineTest`, `VoteTallyEngineTest`, `PhaseExecutionEngineTest`, `RetroEngineTest`, `StanceSummaryEngineTest`, `GroupConversationServiceConcurrencyTest` (timeouts attributed and closed), `GroupConversationStoreTest` / `RestGroupConversationTest` / `McpGroupToolsTest` (owner in the query).

***

## 🐛 fix(backup, datastore): import limits, merge rollback, version-checked delete and in-place writes (2026-09-26)

**Repo:** EDDI (`fix/import-persistence`)

Fix-plan item 14 from the 2026-09-25 review: H15, M-P1 to M-P7, the merge's snippet selection and rollback, and the delete version predicate.

### What changed and why

* **H15 — ZIP import had no size or entry limits.** A 2 MB upload that inflates to gigabytes filled the disk and inodes under `tmp/import`, and the importer then read each file whole into the heap. `ZipArchive.unzip` now enforces three limits, counted from the bytes actually inflated rather than from the entry header: `eddi.backup.import.max-entries` (10000), `eddi.backup.import.max-entry-bytes` (32 MiB — the importer reads each entry whole into memory, and agent documents are kilobytes, so 32 rather than 64 still leaves ample room for a large dictionary) and `eddi.backup.import.max-uncompressed-bytes` (256 MiB). An entry whose header understates its size is caught on the inflated bytes (tested). A breach throws `ZipLimitExceededException`, which every import path (create, merge, legacy preview, upgrade, upgrade preview, and live sync, which unpacks through the same method) answers with **413** and the limit's name. The scratch directory is removed in `finally` as before.
* **M-P1 — `ModifiableHistorizedResourceStore.set()`.** On a historized version it went through the archive's insert-if-absent path and was a silent no-op that still answered success; on the current version it replaced the row by id alone, so an update committed between the read and the write was rolled back. The current write is now version-checked (`storeIfCurrentVersion`), a version that moved into history in between is written there instead, and history rows are rewritten through a new `IResourceStorage.replaceHistory` (MongoDB `replaceOne`, PostgreSQL `UPDATE`). Two callers relied on the no-op being harmless: `PATCH /descriptorstore/descriptors/{id}` now answers **409** for a version that is not current — the live *resource* version is still accepted, because descriptor and resource versions drift and that is the number every client holds. The PATCH also re-checks the current version after its write, so a `PUT` landing in between is a 409 rather than a 204 into history; and `ResourceSharingService.writeBack` does the same and reports such a target as **skipped** instead of applied.
* **M-P2 — regexes built from caller input.** The descriptor listing pasted the `type` query parameter into an unanchored regex (ReDoS, 500s, or one pattern that selected every descriptor of every type); `findByOriginId` did the same with archive file names, so a crafted name matched other resources' descriptors. The type is now an anchored, escaped prefix (per-character escaping, because PostgreSQL's `~` has no `\Q…\E`); `StringUtilities.escapeRegexChars` became public for this. The origin id is an **equality** filter, not a pattern: a new `IResourceFilter.QueryFilter.exact` becomes `Filters.eq` on MongoDB and `=` on PostgreSQL. An escaped `^…$` regex was not enough — on MongoDB `$` also matches before a final newline, so an archive entry named `name\n` would be selected by a merge looking for `name` — and no single strict end anchor works on both backends (MongoDB's `\z` is rejected by PostgreSQL, whose `\Z` MongoDB treats like `$`).
* **M-P3 — foreign ids.** A PostgreSQL archive's UUIDs reached `new ObjectId(uuid)` on MongoDB during preview and merge and surfaced as 500/400. `MongoResourceStorage` now answers "not found" for an id that cannot be an ObjectId in `read`, `readHistory`, `readHistoryLatest` and `getCurrentVersion`, as the PostgreSQL backend already did.
* **M-P4 — merge selection and rollback.**
  * Snippets ignored `selectedResources`, so a merge overwrote live snippets the operator had unticked. They now honour it — on a create import and a first-time sync too, where a snippet left out of the selection is not created. The preview's snippet row carries the snippet's **archive id** (its file name) as `sourceId`, like every other row — it used to carry the local id, and a new snippet had none, so it could not be selected at all. The import also accepts the local id, which is what earlier previews returned.
  * A failed merge deleted what it had created and left every **update** in place, so the target agent was half-promoted. Every merge update (agent, workflow, the eight extension types, snippets) now goes through `updateTracked`, which reads the previous content and descriptor first and records a compensation that writes them back as a new version through the same REST bean — so validation, capability registration, RAG schedule sync and the snippet cache run as on the way in. The restore is version-checked; a resource someone else changed meanwhile is not overwritten, and the conflict is logged.
  * A resource the merge updates **more than once** — an extension two workflows share — keeps a single compensation per store and id: the first update's snapshot, restored over the latest imported version. With one compensation per update, the second one's "previous" was the archive's own content, the first one's restore hit a version conflict, and the rollback silently left the import in place.
  * A resource whose current content **cannot be read** before its first update is no longer updated with a WARN and no compensation — that update would have survived the rollback. The merge fails before writing it (500, naming the resource), and the rollback undoes what it had already written. An unreadable *descriptor* does not stop the merge: the content still restores, only the name and origin id stay as the merge left them, which is logged at `WARN`.
* **M-P5 — delete racing an update.** Deleting by id alone erased a version an update had just committed (never archived, so gone for good), and the delete's tombstone lost the insert race to the update's non-deleted history row, so the resource looked neither live nor deleted. `IResourceStorage.storeHistoryAndRemove` now takes the expected version and has no default. MongoDB upserts the tombstone, deletes with a version predicate and, if the resource moved on, takes the flag back off (retrying once, then logging — nothing else would ever clear it, since that version is never replaced as current again) and throws `ResourceModifiedException` either way, never the raw driver exception; PostgreSQL does the same inside its transaction, so the rollback drops the tombstone with it. A resource that a concurrent delete already removed counts as deleted. On MongoDB, a delete that fails between the tombstone and the conditional delete (write-concern timeout, step-down, network) re-checks whether the version is still live and takes the flag back if so, and every successful update clears a stale `deleted` flag on the version it just archived — without that, a stray tombstone made that version unreadable (404) for good, and a deployment pinned to it stopped loading.
* **M-P6 — legacy migration.** One document that could not be persisted aborted the rest of its collection, and the confirmation was written anyway, so nothing ever retried. Each document is now migrated in its own try, failures are counted, and completion is recorded **per collection** as well as overall. A collection that completed is not swept again, so a document that keeps failing re-sweeps only its own collection on the next start (not every collection, conversation memories included, before agents deploy). Within one sweep only the first failure logs a stack trace.
* **M-P7 — GridFS indexes.** `attachments.files` had no index on the metadata the store filters on, so every `storageRef` lookup, upload quota check, listing and GDPR per-conversation delete was a full scan. `GridFsIndexInitializer` (a `StartupEvent` observer, MongoDB only) creates ascending indexes on `metadata.storageRef`, `metadata.conversationId` and `metadata.grants` at boot within one 10 s budget for the whole pass (each call gets what is left of it, since `withTimeout` bounds a single operation, and a timeout skips the remaining indexes rather than waiting once more per index); none is unique (legacy blobs have no `storageRef`) and a refusal is logged, not fatal.
* **Log injection (CWE-117, code scanning alert #557).** Every log line this change adds routes its caller- or driver-supplied values through `LogSanitizer.sanitize`: the tombstone-healing WARNs in `MongoResourceStorage` (resource id, history row id, driver message), the index WARNs in `GridFsAttachmentStore` and `GridFsIndexInitializer`, the per-document failure lines in `MigrationManager`, and the snippet-name lines in `RestImportService`'s snippet import. Covered by `MongoResourceStorageLogInjectionTest` and `MigrationManagerTest.migrationFailureLinesAreSanitized`.

### Decisions

* Migration retries are bounded by recording completion per collection, not by an attempt counter: a counter would eventually mark a collection done that still holds unmigrated documents, while per-collection confirmation keeps retrying exactly what is broken and stops re-sweeping what is not.
* Merge compensation writes the old content **forward** as a new version rather than deleting the imported version: history is append-only everywhere else, and the imported version stays inspectable.
* `set()` on a history version now writes it rather than refusing, because callers such as the DELETE descriptor filter and the access-index backfill legitimately address older rows. The places where a stale write is a user-visible mistake (PATCH, sharing) refuse it explicitly.
* The unpacking limits live on `ZipArchive`, not the REST layer, so every path that unpacks — including live sync, whose download is capped separately at 256 MB — gets them.

### Compatibility

* Stored configs and archives are unchanged. The merge preview's snippet `sourceId` changed from the local id to the archive id; the import accepts both.
* `selectedResources` now also filters snippets on a create import and a first-time sync.
* `IResourceStorage.storeHistoryAndRemove(history, id)` became `storeHistoryAndRemove(history, id, expectedVersion)`; internal SPI, both backends implement it.

### Not done / follow-ups

* `LegacyDocumentMigrations` still catches a failing transform itself and returns `null` ("nothing to migrate"), so a document whose *transform* throws is skipped rather than counted. It is shared with the import path and was left alone.
* The upgrade strategy (`UpgradeExecutor`) reports per-resource failures as a 207 and does not compensate its own updates; unchanged here.
* The legacy `datastore.mongo.DescriptorStore` (unused in production) still builds the old unescaped type regex.

**Files:** [`ZipArchive.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/ZipArchive.java), [`RestImportService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RestImportService.java), [`IResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/IResourceStorage.java), [`HistorizedResourceStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/HistorizedResourceStore.java), [`ModifiableHistorizedResourceStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/ModifiableHistorizedResourceStore.java), [`MongoResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/mongo/MongoResourceStorage.java), [`PostgresResourceStorage.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/postgres/PostgresResourceStorage.java), [`DescriptorStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/DescriptorStore.java), [`IResourceFilter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/IResourceFilter.java), [`GridFsAttachmentStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/mongo/GridFsAttachmentStore.java), [`MigrationManager.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/migration/MigrationManager.java), [`RestDocumentDescriptorStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/descriptors/rest/RestDocumentDescriptorStore.java), [`ResourceSharingService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/spaces/ResourceSharingService.java), [`import-export-an-agent.md`](/getting-started/import-export-an-agent.md), [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md)

***

## 🐛 fix(llm): tool loop, cascade and summarizer correctness — no tool replay, summary in agent mode, bounded telemetry (2026-09-26)

**Repo:** EDDI (`fix/llm-tool-loop`)

Findings H11a, H11b, H12, M-L1 to M-L6, the tool-cache side-effect finding, M-T3, L1 to L4 and the optional RAG provenance item from the 2026-09-25 code review. Each one below: what was wrong, what changed, where.

### H11a — a retry re-executed every tool the turn had already run

`ToolLoopRunner.runToolCallLoop` ran the whole multi-iteration loop inside `AgentExecutionHelper.executeWithRetry`, which restarts the lambda from `initialMessages`. A 429 or 5xx on iteration 4 re-ran every tool from iterations 0-3: a second POST, a second created agent, a second memory write, each charged and traced twice. The retry now wraps the single `chatModel.chat(chatRequest)` call. A retry resends that one request, with every tool result gathered so far in its transcript, so the model continues where it stopped. Exhausted and non-retryable model failures keep their messages ("Agent execution failed after N attempts" / "Agent execution failed: …"). Unchecked exceptions outside the model call are wrapped the same way, and an abandoned-thread interrupt still becomes a plain `LifecycleException` so the turn settles to ERROR (`wrapLoopFailure`). The one shape change: a plain `LifecycleException` raised inside the loop ("Agent execution cancelled (interrupted)…", or one thrown by a tool executor) now propagates as itself instead of under an extra "Agent execution failed:" prefix. Nothing matches on those messages and the terminal state is the same. The 60-second backoff ceiling still covers the whole turn: every model request of one loop run draws from one shared budget (`RetryConfiguration.executeWithRetry(…, long[] sharedBackoffMs)`), so per-request retries cannot sleep a minute each. `AgentOrchestratorToolCostTest`: the two tests that described the old replay semantics are rewritten, and `retryAfterToolRunDoesNotReplayTheTool` / `exhaustedRetriesFailWithoutReplay` are added.

### H11b — cascade escalation re-ran the tool loop; `maxCostPerRun` ignored tools and the judge

* **Carry-forward.** An agent-mode step now returns its tool exchange (the assistant messages that requested tools, plus the tool results) on `ExecutionResult.toolExchange`. When the cascade escalates, the next step starts from the conversation plus that exchange, so it sees the order that was already placed and does not place it again. The new cascade field `carryToolResultsOnEscalation` defaults to `true`; set it to `false` to go back to starting each step from the conversation alone, for example when a cross-provider escalation rejects the earlier provider's transcript. The returned tool trace now covers every step's calls, not only the winning step's.
* **Cost ceiling.** `maxCostPerRun` is checked against step token cost + judge token cost + tool cost (`RunTotals.runCostUsd()`). `JudgeModelConfig` gains `inputPricePer1M` / `outputPricePer1M`, and negative values fail deployment in `CascadeConfigValidator`, the same rule as the other new price fields. `ConfidenceEvaluator.EvaluationResult` carries the judge's token usage, including when the judge's reply was unusable and scoring fell back to the heuristic. Judge spend is included in `runCostUsd` / `cascadeCostUsd`, and each step's trace entry gets `judgeTokenUsage` and `judgeCostUsd`.
* **Carried calls always have ids.** `ToolLoopRunner.toolExchange` gives a null-id call (Ollama and Gemini bindings pass the provider's id through, often null) a synthetic `gen-` id and gives the same id to the result that answers it, because OpenAI and Anthropic reject a tool call without one.
* **Cross-provider rejection.** A step carrying an exchange whose **first** model request is rejected as a bad request (HTTP 400/422, `InvalidRequestException`) is retried **once from the conversation alone**, the pre-carry behaviour. The first-request condition is carried by a marker exception from `ToolLoopRunner` (`FailedBeforeToolsException`): a failure after that request was answered may follow executed tools, and a retry could repeat their side effects. Authentication, unknown-model and content-filter failures, timeouts, transient 429/5xx and HITL pauses never trigger it. It is logged, recorded on the step trace as `carriedToolExchangeRejected`, and counted as `eddi.llm.cascade.step.errors{type=carried_exchange_rejected}`. This was chosen over carrying only between identical provider types: same-provider escalations (the common case) keep the no-replay guarantee, and cross-provider ones still escalate instead of silently stopping at the cheap step's answer.
* **Timed-out and failed steps' tool cost** now counts toward the ceiling. It is measured as the conversation's tracked tool-cost delta around the step (`IAgentOrchestrator.conversationToolCost`), so a step whose result never arrives still charges its tools.
* **A pause inside an escalated step** keeps the earlier steps' tool calls in the batch trace, ahead of its own.
* **Not carried (follow-up):** the partial tool *exchange* (transcript) of a step that timed out or threw. Its future is cancelled and its result lost, so the next step starts without it. `docs/model-cascade.md` says so.

### H12 — the rolling summary and the gap marker were dropped in agent mode

`ConversationHistoryBuilder` folds the summary into the system message, and `LlmTask` stripped **every** `SystemMessage` for the agent path, then handed the tool loop the *pre-summary* prompt. The summary was lost, along with every turn it covered (skipSteps had already removed them from the history), the windowing gap marker and any system-role log parts. New helpers `ConversationHistoryBuilder.composeSystemMessage` and `withoutLeadingSystemMessage` fix this: the agent path (standard, skipCascade and cascade agent steps) now gets the composed system message and the history minus only that one leading message.

### M-L1 — a HITL resume of a cascade-step pause continued on the base model

`CascadingModelExecutor` records the pausing step on the batch (`PendingToolCallBatch.cascadeStepIndex`, a new nullable field; older batches read null). `LlmTask.executeResume` rebuilds that step's model through the shared `CascadingModelExecutor.resolveStepModel`. If the index no longer exists (the config was redeployed during the pause), it falls back to the base model and logs a WARN. When the continuation pauses *again* (a second gated call), `executeResume` copies the index onto the new batch, so the second resume also stays on the step's model.

### M-L2 — the summarizer model override wrote only `modelName`

New `ModelParameterKeys.withModel(params, provider, model)`. The override is written to every model key the inherited parameters already carry, and always to the provider's own key (`model` for Ollama, `modelId` for Bedrock / HuggingFace / Vertex, `deploymentName` for Azure, `modelName` for the rest) — parameters inherited from a task on another provider can carry only that provider's key. No other key is added, since it would trip the unrecognised-parameter WARN. Both `SummarizationService` and `ToolResponseTruncator` use it.

### M-L3 — unbounded summarizer batch

`ConversationSummaryConfig` gains `maxTurnsPerUpdate` (20) and `maxCharsPerUpdate` (60 000). A backlog is caught up in bounded batches, each batch is shortened turn by turn to fit, and a single turn larger than the budget is cut (the cut notice counts toward the budget). The stored `summary_through_step` records what the summary actually covers. A window with no renderable text is stepped over without an LLM call, so a bounded batch cannot stall on it — judged on the new turns alone, so a previous summary no longer makes a blank window look non-empty and cost a call that re-summarizes an unchanged summary.

`maxCharsPerUpdate` bounds the **complete** request: the previous summary and its section headings are reserved first, and the new turns get the rest. The previous summary is never cut to make room — it is the only record of the turns it covers, and the reply replaces it. When it leaves the new turns less than a quarter of the budget, the update is skipped with a WARN, with no model call; the boundary stays put, so the turns past it keep reaching the model verbatim — the same fallback as a failed summarizer call. The WARN names the `maxCharsPerUpdate` that would let updates resume. Lowering `maxSummaryTokens` does not release the state, since the stored summary is not rewritten while updates are skipped; it only keeps the summaries written afterwards small. Both limits survive the copy `LlmTask.resolveEffectiveSummaryConfig` makes when the summary config inherits the task's provider or model — the common case — so the values a designer sets are the ones the summarizer runs with.

### M-L4 — `convertToObject` failed the turn on `[` or on malformed JSON

`LlmTask.convertResponseToObject` (used by both the live and resume paths) turns an object into a Map and an array into a List. Plain text, or JSON the model truncated, stays the raw string with a WARN. It is tested against the real `JsonSerialization`, not a mock.

### M-L5 — the JSON-mode fallback fired on any `LifecycleException`

`LegacyChatExecutor` now rethrows interrupts and retryable failures (timeout, 429, 5xx, already retried) instead of paying for a second plain request. Only a failure that could mean "JSON mode is unsupported" falls back.

### M-L6 — a transient store error cached an empty snippet map for five minutes

`PromptSnippetService` no longer caches a failed load. It serves the last successfully loaded map (kept across invalidation and expiry). After a failure the store is left alone for 10 seconds (`FAILURE_BACKOFF_MS`), so an outage does not put a driver timeout on every turn; an explicit `invalidateCache()` retries at once. A reload is single-flight (`ReentrantLock`): a caller that finds one in flight gets the last good map instead of starting its own store read, so an outage cannot tie up a burst of request threads before the first failure sets the back-off. Unchecked store exceptions are covered too. The fallback is fail-stale, and that includes an explicit invalidation: if a snippet is edited or withdrawn and the reload right after it fails, prompts keep the previous set — withdrawn content included — until the first successful read (retried every 10 seconds). The old behaviour was fail-empty, which dropped every snippet, safety instructions included. Which of the two a withdrawal should get is open for a maintainer decision (raised in CodeRabbit's security review). Conflict note: `fix/template-injection` (C4c) also touches this class. This change is confined to `getAll` / `loadAllSnippets`.

### Tool cache: HTTP / MCP / A2A tools were cached by default (NEW), and the key lacked agent and source (M-T3)

* `ToolCacheService.mayCache`: tools from `http`, `mcp` and `a2a` sources are cached **only when the task names the tool in `toolCacheScopes`**. That existing field doubles as the explicit opt-in. Built-ins stay cacheable by default, and the stateful built-ins are still never cached.
* `ToolCacheService.namespacedScopeTag` adds `src:<source>` and `agent:<agentId>` to the key. The agent is omitted only for a built-in on the `global` scope, whose definition is identical for every agent. This means a built-in on the `user` or `conversation` scope (websearch, weather, …) is no longer shared between different agents of the same user, which intentionally lowers its hit rate. Pick `global` for a built-in whose result depends only on its arguments.
* **Behaviour change:** an agent that relied on caching an HTTP/MCP/A2A tool must now list it in `toolCacheScopes`. Existing cache entries become unreachable (new key shape) and expire by TTL.

### JSON-mode fallback and 5xx

A self-hosted OpenAI-compatible gateway that answers an unsupported `response_format` with a 5xx used to fall back to a plain request. It now fails the turn, and `docs/langchain.md` names the fix: `jsonResponseFormat: "off"` on the task.

### L1-L4 — LLM telemetry

* **L1** — the `model` meter tag is capped at 100 distinct values per node (the rest share `model="other"`), and names are cut to 128 characters. Spans keep the real name.
* **L2** — langchain4j has no `ModelProvider` value for Jlama or HuggingFace (verified against the 1.20.0 jars: neither overrides `provider()`), so both reported `OTHER`. The decorators now register `LlmTelemetryListener.forModelType(listener, modelType)`, which tags `provider` with EDDI's model type whenever langchain4j reports `OTHER` or null.
* **L3** — the finding was partly wrong: the provider's own timeout does still reach the listener's `onError`, just late (and never for a stream that simply stalls). EDDI now counts its own backstop firing as `eddi.llm.stream.timeouts{path=legacy|tool_loop}` (added to the dashboard and to `docs/metrics.md`).
* **L4** — span status and exception event carry a secret-redacted, 256-character excerpt and the error class. `recordException` (full message + stack trace, every cause included) is no longer used.

### RAG provenance (optional item)

Retrieved context (vector RAG and httpCall RAG) is wrapped by `ToolResultProvenance.markRetrieved` in a `[retrieved context — source '…' …]` … `[end of retrieved context]` envelope before it joins the system prompt. Delimiter-shaped text **inside** the body (the closing line, or an opening header, in any letter case) has its bracket rewritten to `[quoted:` , so a retrieved document cannot close its own envelope and have the rest read as unmarked instructions. The tool-result envelope (`mark`) gets the same treatment; it had the same gap before this branch. The new task field `markRagProvenance` defaults to marking; `false` restores the bare append.

### Config surface / compatibility

New, all optional with safe defaults: `modelCascade.carryToolResultsOnEscalation` (true), `modelCascade.judgeModel.inputPricePer1M` / `outputPricePer1M`, `conversationSummary.maxTurnsPerUpdate` / `maxCharsPerUpdate`, `markRagProvenance` (true). The persisted `PendingToolCallBatch` gains nullable `cascadeStepIndex`. Stored configs, ZIP imports and REST shapes are otherwise unchanged. The Manager's LLM editor does not expose the new fields yet; they are editable in the JSON view (a follow-up for the Manager editors branch).

`ToolLoopRunner.toolExchange` gives a rebuilt (null-id) call with null or blank arguments `{}`, since Gemini parses carried arguments as a JSON object. The stream-timeout dashboard panel shows its legend, now that it plots two series.

**Docs:** `docs/langchain.md`, `docs/model-cascade.md`, `docs/security.md`, `docs/metrics.md`, `docs/monitoring/eddi-full-metrics-dashboard.json`.

**Files:** [`ToolLoopRunner.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ToolLoopRunner.java), [`CascadingModelExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/CascadingModelExecutor.java), [`LlmTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java), [`ConversationHistoryBuilder.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ConversationHistoryBuilder.java), [`ToolCacheService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/ToolCacheService.java), [`LlmTelemetryListener.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTelemetryListener.java), [`ModelParameterKeys.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ModelParameterKeys.java).

***

## 🐛 fix(manager): chat panel and drawer — secret input, stream binding, pause handling, wizard providers (2026-09-26)

**Repo:** EDDI (`fix/manager-chat`)

Fixes the Manager chat findings of the 2026-09-25 UI review (fix-plan item 21): High 6 (Manager side), the chat items of the Medium list, the wizard provider findings, the built-in tool whitelist, L5, L6 and the agent-card undeploy.

### What changed and why

**Secret input (High 6).**

* *Password field ignored while streaming.* A backend `inputField` output item was only honoured on the non-streaming path, and streaming is the default. The key went into the ordinary textarea, in clear, and was sent without `secretInput`, so the backend neither masked it nor redacted it from the audit ledger. The streamed `done` snapshot, a greeting and a reopened conversation now all set the requested field. The field is cleared when the next turn is sent, and the reply decides whether it comes back.
* *The drawer never rendered the field at all.* `SecretInputField` moved out of the panel into [`secret-input-field.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/chat/secret-input-field.tsx). The drawer renders it and sends the value as a secret turn.
* *Secrets came back on reload.* `snapshotToMessages` rebuilt user bubbles from the step's `input:initial`, which holds the raw text of every turn. A turn is now shown as the mask when it answered an `inputField` the previous reply asked for (every such field sends its answer as a secret turn), or when the backend marks it: the output's displayed `input`, or `input:initial` itself, reads `<secret input>`. The first signal is the one that works against the current backend. The simple (`returnDetailed=false`) snapshot every rebuild reads drops the output's `input`, and the backend leaves `input:initial` raw unless a `scope:"secret"` property vaulted it. So reading the output first, as an earlier revision of this branch did, never took effect. A 🔒-mode message answering no field is masked on reload only once the backend returns the masked `input` or scrubs `input:initial`, which #856 adds. This covers reload, resume, undo, redo and rerun.
* *A refused secret answer lost its field.* The send clears the requested field up front. When the backend refuses the message without consuming it (a 409 or a coded stream refusal), the field is now given back, so the retry is not typed into the plain textarea. It comes back holding the answer the user typed, since the field remounts from its `defaultValue` (kept in the in-memory store only, and gone with the next send). A refused 🔒-mode message switches secret mode back on. The panel no longer clears the field itself: it did so before the send could read it.
* *The field is named.* Its visible label is a `<label>` bound to the input, and without a label the placeholder (or the default secret prompt) is the input's accessible name.

**Stream binding (Medium).** Stream events were not bound to the conversation that started them, so switching conversation or agent mid-stream wrote A's tokens into B's bubble. The store now carries a `conversationEpoch`, bumped by `setSelectedAgent`, `clearMessages` and `reset`. A send or start captures it and writes nothing once it has moved on: tokens, `done`, the non-streaming reply, the error bubble and the 409 rollback. `setSelectedAgent` also drops the previous agent's quick replies, requested input field and the processing lock, and each replacement closes the live debug turn, so the old conversation's tool calls no longer show in the new one's status line.

* *Loads are bound too.* `loadConversationIntoStore` (history pick, resume, Continue in Chat) captures the epoch when it is requested and installs nothing if it has moved by the time the read returns. The load ticket also advances on every replacement, and the resume path re-checks after its history fetch. Without this the epoch made things worse. A slow read of agent A's conversation that returned after agent B had been picked and its conversation started would replace B's conversation with A's, under B's name, and every send then went to agent A. The same happened for a history row still loading when "New conversation" was clicked.
* *No sends while a load reads.* A load reads before it replaces anything, so during the read the transcript and `conversationId` still belong to the conversation being left. A send in that window was delivered to the OLD conversation, then the load installed over it and the message vanished from view. The store now carries `loadingConversationId`, set for the length of the read and cleared by the latest load when it settles, or by whatever replaced the transcript. `useSendMessage` refuses a send while it is set, before anything is shown or sent, and toasts why (new key `chat.sendWhileLoading`). The panel and the drawer disable their input, secret field and quick replies for the same interval. A send refused this way keeps the typed draft or secret, in the drawer as well as the panel.
* *Detached streams are bounded.* A detached stream is drained so the turn the user already sent can finish (closing the stream cancels it on the server). It is aborted once `DETACHED_STREAM_GRACE_MS` (120 s) has passed since the switch. Otherwise a proxy that swallows the terminal frame would keep one fetch and one pending mutation open per switch. The trade-off is that a turn still running two minutes after the user left it is cancelled.
* *Returning to a turn that is still running.* A detached stream's reply is ignored, so a user who reopened the conversation before the turn finished got one read without the reply, and it stayed missing until a manual reload. A load that finds the conversation `IN_PROGRESS` now re-reads it every `EXECUTING_TURN_POLL_MS` (1.5 s) until it leaves that state, for at most `EXECUTING_TURN_FOLLOW_MS` (5 min, the ceiling the operator chat uses for an executing turn), then installs the settled transcript. The input shows as processing meanwhile, and the field the running turn is answering is not offered again. The follow stops without writing once anything else replaces the transcript. A failed read or the ceiling leaves the transcript as read. Resuming an agent still skips `IN_PROGRESS` conversations: a stuck one would otherwise hold the chat busy for the full ceiling.

**Pause handling (Medium, `awaiting_approval`; compatible with #841).**

* The streaming endpoint reports its pre-turn refusals as `error` frames with a code, not as statuses. All of those codes are now handled like the refused status they mirror: `awaiting_approval`, `conversation_not_found`, `input_too_large`, `conversation_ended`, `agent_not_ready`, `agent_mismatch`, `quota_accounting_unavailable`, `quota_exceeded`, `processing_restricted` and `restriction_status_unavailable`. The optimistic message and placeholder are rolled back. `awaiting_approval` shows the pause banner; the others toast why the message was not sent. The list is pinned by a test that reads the codes out of `RestAgentEngineStreaming.java`. A failure during a turn carries no code and keeps the error bubble, because that turn ran.
* A refused **409 status** is no longer read as "paused" on its own. The same status also answers "processing another turn", "agent version mismatch" and, with #841, "the conversation changed while your message was queued". The chat now reads the conversation state. It shows the banner only for `AWAITING_HUMAN`, and otherwise toasts the backend's reason (new key `chat.sendRejected`). A state that cannot be read is also a refusal, not a pause. The banner disables the input, and a guess would lock the user out of a conversation that is READY. If it really is paused, the next send is refused and classified again.

**Drawer talked to the wrong agent (Medium).** The drawer shares the chat store with the main panel. It now shows and sends only into a conversation of the agent it was opened for. Its "New conversation" binds the store to that agent first. The agent page's Chat button passes the environment it starts in, so "New conversation" no longer restarts a test-only agent in production. Deploy & Chat names production explicitly.

**Continue in Chat (Medium).** The chat panel ignored `?conversationId=` and reopened the agent's most recent conversation. It now loads the named one, even when that agent is already selected.

**Chat picker by id (Medium).** `useDeployedAgents` de-duplicated by name, so two agents sharing a name collapsed into one entry. It now keys on the agent id.

**Undeploy from the agent card (Low).** The card asks for confirmation first, with the same "End all active conversations" option as the agent page. A refusal shows the backend's 409 reason instead of "Undeploy failed".

**Built-in tool whitelist (Medium, wizard + LLM editor).** Deselecting the last selected tool wrote an empty whitelist, which the backend reads as "no whitelist", so every tool was enabled. The last selected chip is now disabled and explains how to turn built-in tools off (new keys `setupWizard.lastToolHint`, `llmEditor.lastToolHint`).

**Wizard providers (Medium).**

* *huggingface:* `AgentSetupService.createLlmConfig` wrote `modelName`/`apiKey`, but `HuggingFaceLanguageModelBuilder` reads `modelId`/`accessToken`, so the agent deployed and then failed on its first turn. A dedicated branch writes the right names. Sub-agent credential inheritance also reads `accessToken`.
* *gemini-vertex:* the builder reads `modelId`, which is now written, and no key. The provider also needs `projectId` and `location`, and neither setup request can carry them. The agent wizard and operator activation therefore no longer offer it (`isProvisionableBySetup` in [`model-suggestions.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/model-suggestions.ts)). A Vertex agent is created with another provider and switched in the LLM editor. Setup itself (REST and MCP) now refuses `gemini-vertex` before anything is created or vaulted, with a message naming `projectId`/`location` and the way round. Previously it demanded an API key the provider never reads, vaulted it as an orphan secret, and created an agent that failed on its first turn.
* An operator stored with `gemini-vertex` reopens its activation form on the first offered provider, with that provider's default model and no carried key, instead of a select with no matching option. The agent wizard has no stored state that could hold the value.

**L5.** The API-agent request never sent `llmBaseUrl`, so an API agent on Ollama got the default URL whatever was typed. It is sent now.

**L6 (partly refuted in review).** The LLM editor's parameter grid could already set Jlama's `authToken`, but the wizard could not. It now offers an optional Hugging Face token for Jlama (sent in the key slot, which the backend maps to `authToken`). A key is carried across a provider switch only between two key-taking providers, so a Jlama token never becomes an OpenAI key.

### Decisions and scope notes

* **Studio stale keys** was not changed here. The Studio workflow query key missing its version is fixed on `fix/manager-version-after-save`, which owns Agent Studio saving. Touching the same lines here would only conflict.
* **Undo/redo** (`useUndoConversation`/`useRedoConversation`) is owned by `fix/manager-api-contract` and is untouched. `snapshotToMessages`, which they call, now masks secret turns for them too. Undo, redo and rerun are not yet bound to the conversation epoch, so their `replaceMessages` can still land on a transcript switched in the meantime. That is a follow-up for that branch.
* **#839 / #845:** the Manager chat never resumes a pause (it links to the review page), and every chat request declares its body length. Neither needed a change.
* **Follow-ups:** the operator chat (`use-operator-chat.ts`, `operator-history.tsx`) and the conversation page still rebuild user input from `input:initial`, so the same secret leak exists there. The workforce and group wizard provider pickers still offer `gemini-vertex`. Adding `projectId`/`location` to the setup requests would make Vertex provisionable end to end.

**Tests:** `use-chat-stream-binding`, `use-chat-rejected-send`, `use-chat-secret-input`, `chat-drawer-binding`, `chat-panel-continue`, `agent-card-undeploy`, `agent-wizard-providers`, `agent-detail-chat`, `use-chat-load-race`, `use-chat-send-during-load`, `use-chat-executing-turn`, `secret-input-field`, new cases in `resource-detail-llm`, `model-suggestions` and `operator-activation`, and `AgentSetupServiceBranchCoverageTest` (huggingface, gemini-vertex parameters and refusal). The two existing 409 tests now mock the paused state they assert.

***

## 🐛 fix(manager): editors no longer change or drop config data silently (2026-09-26)

**Repo:** EDDI (`fix/manager-editors`)

Fixes the "Editors: data silently changed or dropped" section of the 2026-09-25 UI review, plus known findings U1 and U2. Manager-only; no backend, REST or stored-config shape changes.

### What changed and why

* **HTTP code filter** (`apicalls-editor.tsx`). "Add HTTP Code Filter" wrote `runOnHttpCode: []`. `PrePostUtils` substitutes the defaults only for a *missing* list, and `[]` contains no status code, so the guarded instruction never ran again. The two lists are now handled asymmetrically, as the engine treats them:
  * `runOnHttpCode` is never written as `[]`: an emptied field is absent (defaults 200, 201), and a stored `[]` is flagged with a "Use defaults" repair.
  * `skipOnHttpCode: []` means "skip nothing", which differs from absent (the default skip list, which contains 404 and the other 4xx/5xx codes). "Add" writes `{ skipOnHttpCode: [] }`, an emptied skip field writes `[]`, and only "Use default skip list" makes it absent. Otherwise "run on 404" would be silently cancelled by the default skip list.
  * A warning names every run code that is also in the effective skip list, so a stored `{ runOnHttpCode: [404] }` (skip absent) is visible for what it does.
* **Parameter and argument names** (`llm-editor.tsx`, `mcpcalls-editor.tsx`, new `renamable-key-input.tsx`). The key inputs were read-only, so an added LLM model parameter was stuck as `param<n>` and an MCP argument as `arg<n>`. Names are now editable, committed on blur or Enter, and refused (marked invalid, reverted) when empty, taken, or — for model parameters — a key another section owns (`systemMessage`). New keys use the first free name (`nextFreeKey`) instead of a count that could collide and overwrite a value. Availability is an own-key check, so `constructor` or `toString` are valid names. A rename commits only after a real edit, so a stored key with surrounding whitespace is not rewritten by focusing it. MCP argument rows have stable React keys, so removing a row does not hand its Text/JSON kind to the next row.
* **MCP argument types** (`mcpcalls-editor.tsx`). Values were edited as `String(v)` and written back as strings: `5` became `"5"`, an object `"[object Object]"`. `McpCallsTask` templates strings and passes every other JSON value through, so a row now has a Text / JSON kind; JSON rows keep their type and store only valid JSON. The placeholder is `{memory.current.input}` (was `{{memory.input}}` — wrong syntax and wrong path), and "Save Response" shows the backend's default of `true`.
* **Controls for fields the backend removed or never reads**: "Parallel Tool Execution" and its timeout (removed from `LlmConfiguration.Task`), the RAG "Injection" selects (removed from `KnowledgeBaseReference` / `ragDefaults`), "Paragraph" / "Sentence" chunking (rewritten to `recursive` on save — a stored `paragraph`/`sentence` is still shown, disabled, as "saved as Recursive"; any other unknown strategy, which the server refuses on save, is shown as "not supported" with a warning), and "Batch Calls" (`ApiCall.isBatchCalls` / `iterationObjectName` are read by nothing; batching is `preRequest.batchRequests`). Stored values are left untouched in the JSON.
* **sizematcher bounds** (`rules-editor.tsx`). The preset wrote `min:""`, `max:""`, and `SizeMatcher.setConfigs` parses each with `Integer.parseInt`, so the save failed with a 400. The preset is now `{ valuePath: "", min: "1" }`. A cleared `min`/`max`/`equal` (or an empty key renamed to one) is stored as `-1`, SizeMatcher's own "no bound", and shown as an empty "no limit" field. A non-integer bound is flagged inline.
* **Manual document ingestion with unsaved edits** (`rag-editor.tsx`). The ingest endpoint uses the *saved* knowledge base, so documents went into the old store with the old embedding model. The panel now refuses (drop zone, file picker and "Ingest Text" disabled, with a notice) while the editor is dirty, as the sources panel already did.
* **Crash guards**. A `null` HTTP call `request` (`apicalls-editor.tsx`), an output set without `outputs` or a group without `valueAlternatives` (`output-editor.tsx`), and a property setter without `actions` / `setProperties` (`propertysetter-editor.tsx`) threw during render and took the whole page — and its unsaved edits — down through the root error boundary.
* **Secret scope** (UI side of C2b). HTTP/MCP property instructions are stored by `PrePostUtils` under their scope as-is, so `secret` there kept the value in plain text. It is no longer offered on those rows; a stored `secret` stays visible (disabled option) with a warning. The property setter warns whenever a `secret` row would not be vaulted: a "From path", or a value given as `valueObject` / `valueList` / number / boolean instead of `valueString`.
* **Hints**. The snippet usage hint showed `{{snippets.name}}`, which Qute renders literally; it is now `{snippets.name}` (and the "template resolution" hint says `{ }`, in all 11 locales).
* **Number inputs** (new `number-input.tsx`). `value={x ?? d}` with `parseInt(v) || d` wrote the fallback back into a cleared field, so the next keystroke was appended ("-1" → "-18"). The editors' number fields keep the typed text locally and store `undefined` (the backend default, shown as the placeholder) or an explicit empty value. The whole text must be a number (`1e3` is 1000, and on leaving the field it shows `1000`); an integer field treats `1.5` as invalid instead of storing `1`.
* **Monaco schema scope** (`json-editor.tsx`). One schema was registered with `fileMatch: ["*"]`, so it validated every JSON model on the page, and the next editor to mount replaced it. Each editor now has its own model path, its schema is matched to that path only, and it is removed on unmount.
* **Version diff race** (`version-diff-dialog.tsx`). Fetches started during render and every response was applied, so a slow earlier response could overwrite the pair asked for last. The fetch runs in an effect and only the latest request writes; a fresh `fetchVersion` arrow from the parent no longer refetches.
* **U1** (`ingestion-sources-panel.tsx`). Switching a *saved* Files source to Website (click or arrow key) now asks first: on save `discardRemovedSources` deletes its files and vectors, the same loss removing it already confirmed.
* **U2** (`use-ingestion-sources.ts`, `ingestion-files-panel.tsx`). The file list is invalidated when a run starts, refetched when a run finishes, invalidated after a purge, and refetched after a retried upload; a failed list load is shown with a retry instead of "No files yet"; and the "Run now" in the files banner is not offered on a disabled source.
* **Second review round**. A `secret` property row that has both a `valueString` and a typed value now warns too: PropertySetterTask vaults the string, then writes the typed value over it in plain text. A sizematcher bound is stored trimmed and must fit a Java `int`, since `Integer.parseInt` neither trims nor accepts `2147483648`. The Enter that confirms an IME composition no longer commits a key rename. MCP argument row ids are pruned in an effect instead of during render. A document whose file read finishes after the knowledge base was edited or saved again is refused with a message instead of being ingested into the saved version. The two page tests that still expected the removed "Parallel Tool Execution" control and the old `arg<n>` count now assert the new behaviour.
* **Third review round (CodeRabbit)**. The knowledge-base ingestion panel updates its file-read guard in a layout effect, during the commit that shows the new props: from a passive effect, a `FileReader.onload` landing before it ran still saw the clean, old-version guard and ingested. `NumberInput` keeps text that is not a number ("-", "1.5" in an integer field) in the field instead of reporting `emptyValue` for it — for an optional field that removed the key from the config, and the save path does not look at `aria-invalid` — and on blur restores the stored value. An empty field still reports `emptyValue`.

### What changes for authors

No stored config is rewritten on open. Authoring behaviour does change: a newly added HTTP code filter is `{ skipOnHttpCode: [] }` (it used to be `{ runOnHttpCode: [], skipOnHttpCode: [] }`), and clearing a number field or a sizematcher bound now stores "unset" (`undefined` or `-1`) instead of the UI's copy of a default.

### Not done here

* "Deselecting the last built-in tool enables all tools" (same Editors section) belongs to `fix/manager-chat` (wizard + LLM editor tools whitelist) and is left to that branch.
* `DebouncedNumberInput` in `agent-config-sections.tsx` has the same append-after-clear pattern once its 600 ms commit fires; that file is owned by `fix/manager-version-after-save`, so it is left for that branch (the new `NumberInput` is the drop-in fix).
* A per-editor error boundary (so an *unforeseen* shape costs one editor, not the page) is the shell work in `fix/manager-shell`.

**Files:** [`apicalls-editor.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/apicalls-editor.tsx), [`mcpcalls-editor.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/mcpcalls-editor.tsx), [`llm-editor.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/llm-editor.tsx), [`rag-editor.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/rag-editor.tsx), [`json-editor.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/json-editor.tsx), [`version-diff-dialog.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/version-diff-dialog.tsx), [`ingestion-sources-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/ingestion-sources-panel.tsx), [`ingestion-files-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/ingestion-files-panel.tsx), [`number-input.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/number-input.tsx), [`renamable-key-input.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/renamable-key-input.tsx), [`editor-value-utils.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/editors/editor-value-utils.ts).

***

## 🐛 fix(manager): operations pages that misreported or acted on stale state (2026-09-26)

**Repo:** EDDI (`fix/manager-ops-pages`), Manager UI only. There are no backend, REST-shape or stored-config changes.

### What changed and why

The UI review of 2026-09-25 found that several operations pages showed the wrong thing, or acted on data the operator had not seen. Each fix below has a regression test, and each of those tests was mutation-checked: with the fix reverted, the test fails.

**Schedules (**[**`schedules.tsx`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/schedules.tsx)**,** [**`schedules.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/schedules.ts)**)**

* *UI High 5, frontend half.* The edit form can only describe a chat schedule, and a PUT replaces the whole document. Saving a RAG-ingestion, dream or team-cadence schedule therefore dropped its metadata marker, and from then on the schedule messaged an agent instead of crawling or consolidating. Every schedule the backend treats as system-managed now has no Edit action. The markers are checked the way the backend checks them: `hitlType=hitl_timeout`, `ragIngestion=true`, or a non-empty `dreamType` or `teamCadenceType`. Such a schedule carries a "System" badge. An ordinary edit now also echoes `tenantId`, `allowSelfScheduling` and any free-form `metadata`; before, a PUT without them reset each one to its Java default. The backend carry-over for the same fields belongs to `fix/pipeline-scheduling`.
* `enabled` was read from the row as it stood when the dialog opened. A disable made meanwhile was undone by the save. The value now comes from the freshest copy in the polled list. **This narrows the problem but does not close it.** A disable made within the last poll interval (about 10 s) is still overwritten, because a configuration PUT carries `enabled` at all. The real fix is for the backend to stop taking `enabled` from a PUT, as it already does for `fireStatus`. That belongs to `fix/pipeline-scheduling`.
* `persistentConversationId` is carried over from the stored row on every update, so an edit to it was silently discarded. The field is now read-only when editing, with a hint explaining why.
* **Dead-letter panel.** The panel lists *fire logs*, but Retry and Dismiss act on the *schedule*. A failed log whose schedule is not on the listed page is looked up by id. The list is one page, and it hides HITL timeouts from non-admins. Such a schedule is reported as gone only on a 404, and it keeps its actions if it is dead-lettered. Row keys are `id:<id>` or `idx:<n>`, so a numeric id cannot collide with an index. A FAILED schedule still in its backoff answered Retry with a 404. Dismiss is a `markCompleted`, and on a claimed schedule it could clear a live claim and cause a double fire. Both actions are now offered once per schedule, on its newest log, and only while that schedule is `DEAD_LETTERED`. Every other row shows the schedule's current status, or says the schedule no longer exists. Fencing `markCompleted` on the server is `fix/pipeline-scheduling`'s work.
* **Cron preview.** A day field that begins with `*` counts as starred even when it has a step, and the two day fields are then ANDed. That is what `CronParser` does. The preview had treated `*/2` as restricted and ORed the fields, so `0 9 */2 * MON` previewed every Monday while the server fires only on odd-numbered Mondays.

**Logs (**[**`session-log-store.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/session-log-store.ts)**,** [**`use-logs.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-logs.ts)**,** [**`logs.tsx`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/logs.tsx)**, new** [**`log-entries.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/log-entries.ts)**)**

* *P1.* The ring buffer was reseeded only when the view was empty. After a reconnect, or after the last viewer left and came back, the lines logged in between never appeared, and nothing showed that they were missing. Both the session store and the filtered stream now reseed on every open.
* `RestLogAdmin.streamLogs` replays up to 50 lines on each connect, and these were prepended blindly. They were duplicated, and they landed above newer lines. Incoming lines are now merged in timestamp order (`mergeNewestFirst`). De-duplication counts copies (`newByMultiplicity`): an incoming line is admitted only once the batch holds more copies of it than are already shown. Repeated lines, such as a retry loop logging in one millisecond, are real, and the first version of this fix collapsed them. One case still cannot be told apart without a sequence id from the backend: a single live line that is an exact twin of one already shown.
* Pause worked only on the filtered path, and there it dropped lines. It now freezes the displayed list in both modes. Lines keep being collected, so Resume shows what arrived during the pause.
* Rows were keyed by list index. Each new line therefore remounted the whole list and collapsed any open stack trace. Rows are now keyed by line identity plus an occurrence number (`keyedOldestFirst`). Repeated lines get distinct keys, and a new line never changes an older line's key.
* History showed only the newest 100 rows. `useHistoryLogs` now pages by row offset (`useInfiniteQuery`), shows a "Load older entries" button, and removes rows that shifted from one page into the next, counting copies so that repeated rows within a page survive.

**Debugger (**[**`live-log-viewer.tsx`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/debugger/live-log-viewer.tsx)**,** [**`pipeline-trace.tsx`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/debugger/pipeline-trace.tsx)**,** [**`cost-dashboard.tsx`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/debugger/cost-dashboard.tsx)**,** [**`use-debug-events.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-debug-events.ts)**, new** [**`audit-pages.ts`**](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/audit-pages.ts)**)**

* The live log's initial fetch replaced the lines that had already streamed in, and showed the newest-first REST result upside down. It now merges, renders oldest-first, reseeds on reconnect, and pauses the same way as the Logs page.
* The debug store is global, and nothing cleared it (`reset` had no caller). After a switch of conversation or agent, the previous conversation's turns stayed on screen and new ones were added to them. `bindConversation` is called from the drawer. On a change from one conversation to another, it clears everything, the running turn included. On the first binding (null to X), it keeps the turn already recording, because that turn is for X.
* Live turns were matched to audit entries by `turnIndex`, which counts turns seen in this session, against `stepIndex`, the conversation's own step number. So costs and models were attached to the wrong turn. `resolveLiveTurnSteps` now matches finished turns to steps in order, and it refuses to guess:
  * A step matches only if its (taskType, durationMs) pairs are exactly the turn's completed tasks. The backend sends the same measured duration to both the stream and the ledger.
  * When more than one unclaimed step fits, the result is ambiguous. On a rule-based agent, where every task takes 0 ms, that is the common case, and no audit data is shown.
  * Each step can be claimed only once. A later turn only considers steps after the last one claimed.
  * An in-flight turn is never matched.
  * When no step fits yet, the ledger is re-read every 4 s for up to a minute. The ledger flushes a few seconds after the turn, so an immediate read would miss it.
* The cost dashboard and the pipeline trace read a single page of 200 audit entries. Entries come newest-first, so on longer conversations "Total cost" covered only the most recent turns. Now one shared, per-conversation query (`useDebuggerAudit`) serves both. It walks the whole trail once (`getWholeAuditTrail`: pages of 500, at most 20), and after that re-reads only the newest page and merges it (`refreshAuditTrail`). It walks the full trail again only when that newest page does not overlap what is already loaded. Entries are de-duplicated by `id`, because the ledger sorts by timestamp alone and offset paging can return the same entry on both sides of a page boundary. The dashboard says when the page cap cut the total short.

**Other pages**

* *Coordinator (*[*`coordinator.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/coordinator.tsx)*).* Once the first SSE snapshot arrived, it overrode the polled status permanently, so after a disconnect the page stayed frozen. The page now shows the SSE status only while the stream is up, and the polled status otherwise. A failed dead-letter read used to show a green "No dead-letter entries". It now shows an error state with a retry.
* *Conversations (*[*`conversations.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/conversations.tsx)*).* The step count on each row came from a full conversation-log request per row, up to 100 per page. The listing already carries it as `conversationStepSize`, so that value is used. The server-side search is now debounced (300 ms).
* *Conversation monitoring (*[*`conversation-monitoring.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/conversation-monitoring.tsx)*).* "End selected" sent the state from the last poll, which could be up to 10 s old, and the backend acts on that state. A conversation that had paused for approval meanwhile was ended as if it were still running, and its approval was never cleaned up. This is the UI side of C1b. The list is now re-read on confirm, and the fresh state is what gets sent. If a paused conversation appears that the dialog did not warn about, nothing is ended and the dialog stays open so the operator can check again. Paused conversations are compared by id, not by count. A pause that lands between the re-read and the POST can still slip through; closing that needs the backend to read the state itself (C1b). If the re-read fails, nothing is ended.
* *Workflow editor (*[*`workflow-detail.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workflow-detail.tsx)*, new* [*`workflow-steps.ts`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/workflow-steps.ts)*).* "Add Task" appended new steps after `ai.labs.templating`. A new output or LLM step therefore ran after templating, and its placeholders reached the user unresolved. New steps are now inserted in front of the last templating step (see the review follow-up below).
* *Orphans (*[*`orphans.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/orphans.tsx)*).* `DELETE /administration/orphans` has no selection parameter. It purges every orphan, re-checking each one just before deleting it. The per-row checkboxes and "Delete N selected" opened the purge-ALL confirmation, which offered a selection the server cannot honour. A client-side delete per resource would skip that re-check, so the selection UI has been removed and "Purge All" is the only action. "Last scanned" now shows when the scan actually ran (`dataUpdatedAt`), not the time of the last render.
* *Sync (*[*`sync-page.tsx`*](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/sync-page.tsx)*).* A preview that failed outright left the page silent. It now shows the error. Before, a failed re-preview left the previous previews in place, as did a response that did not cover a mapping, and "Sync Selected" stayed enabled on those old diffs. Previews are now cleared when a new preview starts, and a missing row is marked as failed. Changing the source URL or token drops the agent list and every preview, because those belonged to the previous source. Errors now go through `getErrorMessage`, which includes the HTTP status.

### i18n

New keys, in all 11 locales: `schedules.systemManaged`, `schedules.systemManagedHint`, `schedules.persistentConversationIdLocked`, `schedules.failedScheduleGone`, `logs.loadOlder`, `costDashboard.partial`, `orphans.confirmPurge`, `conversations.endStateUnavailable`, `conversations.endStateChanged` and `syncPage.noPreviewReturned`. Removed, because nothing uses them any more: `orphans.confirmPurgeAll`, `orphans.deleteSelected`, `orphans.selectAll` and `orphans.deselectAll`.

### Overlap with sibling branches

* `src/lib/api/schedules.ts` is also in the API layer that `fix/manager-api-contract` edits. This branch touches only the two cron helpers, `parseCron` and `dayMatches`.
* The locale files will conflict textually with every other Manager branch. The keys are appended at the ends of their namespaces.
* `fix/pipeline-scheduling` owns the backend halves of the schedule fixes: carrying over metadata, `tenantId` and `allowSelfScheduling` on PUT, and fencing dismiss.

### Not done here

* The audit ledger has no tie-breaker in its sort (timestamp only on Mongo, `created_at` on Postgres). The client de-duplicates by id, but a secondary sort on `sequence` or `_id` belongs to the audit store owners.
* The sync page still auto-matches only against the first page of local agents. That follows from High 2, the pagination bug, which is `fix/manager-api-contract`'s work.

***

## 🔐 fix(manager): secret overwrites, unbound approvals, unverified audit shield (2026-09-26)

**Repo:** EDDI (`fix/manager-security-ui`)

Manager findings from the 2026-09-25 UI review that change more than the operator asked for, or claim more than was checked. Every change has a regression test that fails without it.

### What changed and why

* **High 7 + S1 (frontend) — silent secret overwrite and grant widening.** Rotate POSTed to a `/rotate` path EDDI has never had and fell back to a PUT without `allowedAgents`, which a backend before the vault-key-safety fix (#836) stores as `["*"]` with a blank description. Rotation is now a PUT carrying the grant and description **re-read just before the write** (not the row clicked minutes ago), and a key deleted in the meantime is refused instead of re-created. The re-read and the existence check use the exact per-key metadata read, `GET /secretstore/secrets/{tenant}/{key}` (404 = absent, anything else is raised, never read as "free to create"). "Add Secret" on the secrets page, the picker's create dialog (LLM, channel, API-key fields) and "Add Variable" check a fresh listing and refuse an existing name in place (the secrets page offers Rotate instead). The dead `/rotate` MSW mock and its OpenAPI-contract exemption are removed.
* **High 8 — the approval queue could approve a pause nobody saw.** Queue rows (rule, group) and the inline tool-call panel, plus the conversation-detail banner, now bind the decision to the pause that was shown ([`hitl-pause-binding.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/hitl-pause-binding.ts)): the backend's `pauseId` when approval-status reports one (#839 — the backend then answers 409 for a stale decision), otherwise a fresh approval-status read compared on state, pause kind, start instant and call ids, refusing on any mismatch — and refusing outright a shown pause that carries neither an id nor a start instant. The group approve answers every wrong-state refusal (a #839 pause mismatch included) with "Group conversation is not awaiting approval…"; that 409 is read as a changed pause too, so the queue says "this request changed since you opened it" and refreshes the group queue.
* **High 9 — green "SIGNED" shield without verification.** The audit banner called no verify endpoint and went emerald whenever every entry had an `hmac` field. It now calls `GET /auditstore/verify/…` and mirrors the report's `intact()` / `tamperingSuspected()`: green only for verified, red for disproven signatures or a broken chain, amber for anything unproven (unsigned, `UNKNOWN_KEY` from #836, skipped legacy rows, incomplete chain, duplicates), neutral while loading, on error and without a signing key. Failing entries are marked in the timeline. The verify call checks the newest 1,000 entries while the timeline pages in 100 at a time (both newest-first over the same store query), so once more rows are loaded than the report checked, the banner turns amber and names how many loaded entries are older than the checked window instead of keeping VERIFIED above them. The superseded strings are removed; fr/es/ja carried them in English.
* **Share dialog.** A visibility click cascaded over the whole config graph immediately, replacing each resource's own visibility. It now proposes, explains, offers `cascade=false`, and applies on confirm; a failed apply keeps the panel open with the reason.
* **Channels.** Bot token and signing secret are reference-only (`${vault:…}`; `${vars:…}` is refused because the router resolves vault references only); the create dialog will not advance and a save is refused with a literal. A plaintext value loaded from an older channel is masked in the reference-only picker until typed over (re-masked when another channel is loaded into the page), redacted in the Raw Configuration panel, and flagged by a banner. The detail draft is seeded once per id and version, so a refetch returning changed data no longer overwrites unsaved edits.
* **GDPR.** A 207 partial erasure toasted "deleted successfully"; it now warns.
* **Smaller items.** Vault paths percent-encode tenant and key; the tenant field is trimmed once; secret values are stored exactly as typed (no `.trim()`); shared mutations are reset when a dialog opens for another row, and the rotate dialog cannot be closed mid-request; approvals track busy state per conversation id and use `mutateAsync` (a TanStack mutation remembers only its latest call, which re-enabled row 1 while its resume ran and dropped its toast).
* **Lost-key flow (#836) — moved out.** An "Adopt master key" step for the secrets danger zone was written, then taken off this branch because its endpoint exists only in the unmerged #836; on `main` it could only answer "not supported". It is kept on the local branch `feat/manager-adopt-master-key` for after #836 merges.
* **Review follow-ups (CodeRabbit).** The audit verification no longer runs on its own timer beside the trail's: it is requested once the newest page has arrived and again after each refresh of that page, and a report requested before the newest rows were read is shown as "Verifying…", never VERIFIED — a refreshed page of 100 swaps in newer rows without moving the count `uncoveredCount` compares. The secret mock handlers (list, metadata GET, store, grant, delete) now share one store reset after every test, so a key created in a test is found by the next lookup and a deleted one is gone.

### Refuted / partial

* **"Every channel's full config, tokens included, is fetched into the query cache."** Partly wrong: the list query caches only the projection (`channelType`, `targetCount`, `channelId`), never `platformConfig`; a test pins that. The per-row config read itself stays — the descriptor carries none of those fields and there is no projection endpoint. It carries no plaintext token for channels saved through the Manager since this change; a legacy channel stored with one still sends it on that read until it is re-saved (its detail page now says so).

### Compatibility

* Works against current `main` and against #836 / #839: `pauseId` is sent only when approval-status reports one. Rotation sends the grant it re-read explicitly, so it keeps working on both; this is not equivalent to omitting it, since a grant narrowed by another admin between that read and the PUT is widened again — a race that remains until rotation stops sending the grant (see Follow-ups). No backend change in this branch. No UI surface here depends on #838 or #840.
* Behaviour change: saving a channel whose stored token is plaintext is refused until it is replaced by a vault reference.

### Follow-ups

* The operator chat (`use-operator-chat.ts`) and the group transcript's streaming approval (`streamGroupApproval`) still send unbound decisions — other branches own those files.
* "Add" is checked client-side against a fresh listing; closing the last race needs a create-only precondition on the secret and variable PUTs.
* Rotation re-reads the grant but still sends it: if another admin narrows the grant between that read and the PUT, the PUT restores the older, broader list. No UI-side check closes that window (a read after the write cannot tell who widened it). On `main` an omitted grant is still stored as `["*"]`, so the grant must be sent until #836 (omitted = kept) merges; then rotation should stop sending it.
* Land `feat/manager-adopt-master-key` (the lost-key UI) after #836.
* A per-channel projection endpoint would let the channel list stop reading full configs.

**Files:** [`secrets.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/secrets.ts), [`secrets.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/secrets.tsx), [`approvals.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/approvals.tsx), [`audit.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/audit.tsx), [`audit-verify.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/audit-verify.ts), [`share-dialog.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/workspaces/share-dialog.tsx), [`channel-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/channel-detail.tsx), [`channel-secrets.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/channel-secrets.ts), [`gdpr.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/gdpr.tsx).

***

## 🔒 fix(http): one deadline over the whole exchange, discarded redirect bodies, bounded search/weather answers, weather key kept from the model (2026-09-26)

**Repo:** EDDI (`fix/outbound-http-hardening`)

Outbound fetches made on a model's or a peer's behalf could be held open, or made to exhaust the heap, by the server they fetched from. Most of that was fixed independently on main by #862 (see [its entry](/reference/changelog.md)); this branch now carries only what #862 left open.

### What changed and why

* **One wall-clock deadline covers the whole exchange, body included (H16, time).** Main's `SafeHttpClient` consulted its budget only *between* redirect hops. The JDK's per-request timeout stops counting when the response headers arrive, so a server that sent headers and then trickled the body held the call open for as long as it liked. Every send path (`send`, `sendValidated`, `sendNoRedirect`, `sendValidatedNoRedirect`, and the `send*Bounded` pair) now runs through `sendAsync(...).get(remaining)` against one deadline and cancels the exchange when it passes.
  * The budget is `max(3 × connect timeout, request timeout)`: 30 s by default, longer for a caller that asked for longer, such as an A2A peer configured for 120 s.
  * Async failures keep their exception type. The caller's stack frames are attached as a suppressed `CallerFrames`.
  * The timeout message names scheme, host, port and path only. A query string can carry a key (`key=`, `appid=`), and the tools hand a failed call's message to the model.
  * A streaming handler (`ofInputStream`) completes when the headers arrive, so the deadline covers its headers only. The `send*Bounded` paths already bound the body with main's `BoundedBodyReader` (cap plus a deadline of 4 × the request timeout).
* **Redirect bodies are discarded (R8).** A 3xx body used to go to the caller's handler. On the `send*Bounded` paths that was an `InputStream` per hop that nothing ever saw or closed, holding the hop's connection. For `ofString` it was a body buffered whole, however large. It now goes to a discarding subscriber that reads at most 64 KiB, or nothing when the declared `Content-Length` is larger, then drops the connection and lets the redirect proceed. The caller's handler only ever sees the final response. `sendNoRedirect` still hands the 3xx body to its caller, which decides what a redirect means.
* **The search and weather answers are bounded (H16, memory).** `WebSearchTool` (Google, DuckDuckGo, Wikipedia) and `WeatherTool` were the two callers #862 left on an unbounded `ofString`. They now read through main's `SafeHttpClient.sendBounded`, at 2 MiB and 1 MiB. A truncated answer is refused rather than parsed as a JSON fragment.
* **`WeatherTool` no longer hands the operator's key to the model (H17).**
  * `units` went into the URL verbatim. A value with a space in it made `URI.create` throw with the whole URL, `appid=<key>` included, as its message. That message was returned as the tool's answer and logged with its stack trace. `units` is now an allowlist (`metric`/`imperial`/`standard`, case-insensitive, blank means metric), and anything else gets a plain error before any request is built.
  * The key is URL-encoded into the request.
  * Every error message passes through an `appid=` redactor before it reaches the model or the log, and the log line no longer carries the throwable.
  * Incidental fix: `standard` (Kelvin) is no longer labelled °F.
* **The NAT64 local-use prefix `64:ff9b:1::/48` is blocked whole (M-T1).** Main unwraps its trailing 32 bits, as for the well-known `/96`. RFC 8215 lets a network use any RFC 6052 prefix length inside the `/48`, though. Behind a `/48` translator `64:ff9b:1:7f00:0:100:808:808` is 127.0.0.1, and it passed as 8.8.8.8. [`security.md`](/security-and-compliance/security.md#private--internal-ip-blocking) now lists the ranges main actually blocks: it had still claimed only IPv4-mapped addresses were unwrapped.

### Reconciled with main (#862)

Main landed an independent fix for most of the same findings while this branch was open. Where both implemented the same thing, main's version was kept and this branch's copy removed:

* Bounded downloads use main's `sendBounded`/`sendValidatedBounded` and `BoundedBodyReader`, with the same property names (`eddi.tools.web-scraper.max-response-bytes`, `eddi.tools.pdf-reader.max-download-bytes`). This branch's handler-level `BoundedBodyHandlers` is gone. The PDF and attachment downloads keep main's timeouts: the body gets 4 × the request timeout, so the 120 s requests this branch added are not needed.
* The A2A client stays on main's raw `HttpClient` with a bounded body read, instead of moving to `SafeHttpClient.sendNoRedirect`.
* Inline OpenAPI `$ref` inspection is main's `rejectUnsafeInlineRefs`, which also fails closed. Two of main's decisions differ from this branch's: `http(s)` references stay allowed, guarded by `setSafelyResolveURL`, and a schema property literally named `$ref` is refused. This branch's [`McpApiToolBuilderExternalRefTest`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/engine/mcp/McpApiToolBuilderExternalRefTest.java) is kept against main's guard. It covers a 3.3M-character JSON spec, with a plain and with an escaped key, an unreadable spec, remote specs not resolving `file:` references, and local references still resolving. All of these pass on main unchanged.
* `UrlValidationUtils` is main's. It adds Teredo unwrapping, and makes two decisions that override this branch's: `198.18.0.0/15` **is** blocked, and of `240.0.0.0/4` only `255.255.255.255` is. This branch had left 198.18/15 open for fake-IP DNS proxies (Clash, Surge) and had blocked all of 240/4.
* The startup validation this branch added for the size limits is dropped with it: main falls back to the default for a non-positive value.

**Review round (2026-09-29):**

* The always-on metadata refusal, which also runs where SSRF protection is off, now decodes every RFC 6052 layout inside `64:ff9b:1::/48`: /48, /56, /64 and /96. A translated `169.254.169.254` is refused wherever the translator puts it. The /48 as a whole stays blocked only by the private-address policy, so a public target behind a local translator still passes when SSRF protection is off.
* `WebSearchTool` URL-encodes the Google API key and `cx`. A failure message that names the request URI has its `key=` value redacted before it is logged or returned to the model, and the throwable is no longer logged.

**Files:** `SafeHttpClient.java`, `WebSearchTool.java`, `WeatherTool.java`, `UrlValidationUtils.java`, `docs/security.md`. Tests: `SafeHttpClientBodyBoundsTest`, `UrlValidationUtilsEmbeddedIPv4Test`, `McpApiToolBuilderExternalRefTest`, plus cases in `WebSearchToolTest` and `WeatherToolExtendedTest`.

***

## 🐛 fix(scheduling, pipeline, sync): DST-correct cron, capabilityMatch templating, batch cap, fair due polling, safe schedule edits and dismissals, sync descriptor catch-up (2026-09-26)

**Repo:** EDDI (`fix/pipeline-scheduling`)

Seven review findings in the scheduler, two pipeline conditions/tasks and agent sync. Each fix has a regression test.

### What changed and why

* **M-Q1: cron across DST.** `CronParser.computeNextFire` walked real time minute by minute. So `30 2 * * *` in `Europe/Berlin` fired twice on 2026-10-25, when 02:30 happens twice, and not at all on 2026-03-29, when 02:30 never happens. The fix follows Vixie cron:
  * A **fixed-time** expression has no `*` in either the minute or the hour field. It now walks local wall-clock times. A skipped local time fires at the transition instant; a repeated one fires only at its first occurrence.
  * **Wildcard** expressions (`*/15 * * * *`, `5 * * * *`) keep the real-time walk, so they keep a true real-time cadence through the repeated hour.
  * Schedules in UTC (the default) are unaffected.
  * Two consequences of the Vixie rule are documented in [`scheduling.md`](/conversations-and-orchestration/scheduling.md) and pinned by tests:
    * Two fixed times that both resolve to the transition fire once: `0 2,3 * * *` fires a single time on the spring-forward day. `30 2,3` still fires twice.
    * A fixed hour *range* (`0 0-23 * * *`) counts as fixed-time, so it does not repeat the doubled hour on fall-back. `0 * * * *` does.
  * Covered beyond whole-hour shifts: Lord Howe (30-minute gap and overlap) and Havana (transition at midnight).
  * Files: [`CronParser.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/CronParser.java), tests in `CronParserTest` (DST section).
* **M-Q2: `capabilityMatch` templating never ran.** `resolveTemplate` only sent a value to the engine when it contained `{{`. No Qute expression contains that, so the documented `{properties.requiredSkill}` was used literally as the skill name and the condition silently failed. Its test mocked the engine, so it could not catch this.
  * The gate is now any `{`.
  * A skill that renders blank fails without querying the registry. A strategy that renders blank falls back to `highest_confidence`.
  * The new tests use the real `TemplatingEngine`, set up with the production NOOP missing-property mapper.
  * The Javadoc and [`capability-match-guide.md`](/conversations-and-orchestration/capability-match-guide.md) no longer describe a "known limitation". Double-brace values were never resolved, so no stored config that worked before changes behaviour, with one theoretical exception:
  * One theoretical behaviour change: a *literal* skill or strategy name containing `{` followed by an identifier (say `c{sharp}`) is now rendered as a template instead of used verbatim. Nothing documented produces such a name. Qute's `{|…|}` keeps one literal if ever needed.
* **M-Q3: batch fan-out was uncapped.** A fire-and-forget `preRequest.batchRequests` sent one request per element of an array that usually comes from an upstream response or from an LLM.
  * New config field `batchRequests.maxBatchSize`. Unset or `<= 0` means the deployment default (`100`). Saving a value above the deployment ceiling (`1000`) is **refused with 400** by `RestApiCallsStore`, on create, update and duplicate alike. A value stored before an operator lowered the ceiling runs at the ceiling, with a WARN. Both numbers are operator-configurable: `eddi.httpcalls.batch.default-max-size` and `eddi.httpcalls.batch.max-size-ceiling`, in [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md) and `application.properties`. Raising the default restores the old behaviour for a deployment without editing stored configs. The keys sit under `eddi.httpcalls.*` next to the existing httpcalls knobs. The review suggested `eddi.apicalls.batch.*` only as an example.
  * A batch over the limit is **refused as a whole**, before any request is built or sent. The turn fails with a message that names the knob. A truncated batch would have reported success while dropping the tail.
  * Files: [`ApiCallExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java), [`BatchRequestBuildingInstruction.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/apicalls/model/BatchRequestBuildingInstruction.java), [`httpcalls.md`](/agent-configuration/httpcalls.md), test `ApiCallExecutorBatchCapTest`.
* **M-Q4: unsorted due query.** The reviewer confirmed the verified downgrade: this was never starvation, only fairness once more than one poll batch (100) is due.
  * `findDueSchedules` now orders by `nextFire`, then id, on both Mongo and Postgres, so the most overdue fire goes first. The existing `(enabled, nextFire, fireStatus)` index serves the sort.
* **UI High 5, backend half: PUT turned system schedules into chat schedules.** `carryOverNonEditableFields` now also carries over `metadata`, `tenantId` and `allowSelfScheduling`, but **only when the body omits them**.
  * `metadata` selects the fire path (dream, team cadence, RAG ingestion, HITL). The Manager never echoed it, so an edited system schedule began messaging the agent instead.
  * Naming a field still sets it. `"metadata": null` and `{}` both clear it.
  * "Absent" is tracked by a non-serialized marker set by the setter, for `metadata` and for `allowSelfScheduling`, which is a primitive. `tenantId` has no marker: null or absent keeps the stored tenant.
  * **Authorization, tightened in the same change** (review MAJOR 1). A metadata-less PUT used to strip the ingestion marker, which was harmless: the result was a chat schedule. Keeping the marker let anyone with USE on some agent and VIEW on a knowledge base re-cron that knowledge base's crawl to every minute. That bypassed the EDIT gate that `fireNow` and the ingestion endpoints enforce. `updateSchedule` now judges the **stored** row:
    * **RAG ingestion:** refused for everyone with **409**. The row is minted from the knowledge base's ingestion source, whose `cron` is the source of truth and is re-applied on every knowledge-base save. Change it there.
    * **Team cadence:** requires **EDIT on the group**, the gate `addCadence` applies. There is no cadence-update endpoint, so this PUT stays the way to change a cadence's cron.
  * The Manager needs no change. Its own `buildConfig` fix belongs to `fix/manager-ops-pages`.
* **Dead-letter dismiss could reset a live claim.** `dismissDeadLetter` called the unfenced `markCompleted`, which matches the row in any state. The Manager offers "Dismiss" on failed fire *logs*, whose schedule may be running again. Dismissing reset the live claim, the next poll fired the schedule a second time, and `lastFired` moved although nothing had fired.
  * New `IScheduleStore.dismissDeadLetter(id, nextFire)`, implemented on Mongo and Postgres. The write is conditional on `fireStatus = DEAD_LETTERED` and leaves `lastFired` alone. It also leaves `enabled` alone when there is a next fire: dead-lettering never clears `enabled`, so a disabled dead-lettered row was disabled on purpose (by `/disable` or an agent undeploy) and stays disabled until `/enable`. The API description and `docs/scheduling.md` now say so, and tests in both stores pin it.
  * The contract declares it: `@APIResponse(409)` on `IRestScheduleStore.dismissDeadLetter`. The Manager still offers "Dismiss" on failed fire logs and shows a generic toast on 409. That fix is a follow-up for `fix/manager-ops-pages`.
  * REST returns **409** when the schedule is not dead-lettered, and also when the state changes between the read and the conditional write. A schedule that is dead-lettered still gets 200.
  * The schedule id is a path parameter, so both stores' "Dismissed" log lines pass it through `LogSanitizer.sanitize` (CodeQL alert 556 on the Postgres one). Postgres's pre-existing "Requeued" line had the same gap and is sanitized too. Each store's unit test now drives a CRLF-forged id through `LogCaptureSupport`.
* **G1: sync could never recover a lagging descriptor.** `bumpDescriptor` read the descriptor at exactly the version just written. After one lost bump the descriptor stays behind the resource, so that read failed on every later sync, and the resource could never be deployed. The existing "heals" test answered a descriptor at *any* version, so it passed anyway.
  * The executor now walks a lagging descriptor forward one version at a time, at most 50 versions in one run. Every skipped resource version gets the descriptor that a deployment of that version looks up.
  * The "heals" test now uses a real lagging fixture.
* **G5: sync counters.**
  * Rewritten workflows, and extensions their new version references, count as `updated` only once the **agent** that loads them has been written and its descriptor moved to the new version. An agent write the store refuses without throwing is now reported as a failure instead of leaving a null URI. Earlier, an extension counted as `updated` as soon as it was written.
  * A changed workflow the operator **deselected** is no longer counted as `skipped`, which means "identical".
  * A snippet create counts as `created` only when the store accepted it (201) and its descriptor was written. Before, it could appear under both `created` and `failures`.

### Compatibility

* **Stored configs:** new optional field `maxBatchSize`. A batch config whose array grows past 100 now fails the turn instead of sending everything; raise `maxBatchSize` (up to 1000) if that is intended.
* **REST:** `POST /schedulestore/schedules/{id}/dismiss` answers 409 for a schedule that is not dead-lettered. It used to answer 200, after silently resetting whatever state the schedule was in.
* `PUT /schedulestore/schedules/{id}` on a RAG-ingestion schedule now answers 409; on a team-cadence schedule it needs EDIT on the group.
* **Properties:** new `eddi.httpcalls.batch.default-max-size` (100) and `eddi.httpcalls.batch.max-size-ceiling` (1000).
* **REST, httpcalls:** create/update/duplicate of an apicalls config whose `maxBatchSize` exceeds the ceiling answers 400.
* `PUT /schedulestore/schedules/{id}`: omitted `metadata`, `tenantId` and `allowSelfScheduling` are kept instead of being nulled or reset to false.

### Coordination

* `ScheduleFireExecutor` is untouched. `fix/conversation-turn-state` edits its rollover logic.
* Authorization: one check was **added** — the stored-row guard on `updateSchedule` for ingestion and team-cadence schedules — because the carry-over would otherwise have widened what an existing check lets through. No other authorization check changed. Workspace scoping of schedules remains with `fix/workspace-authz-scoping`, which should expect this guard in `updateSchedule`.

***

## 🐛 fix(rag): ingestion keeps what it should, removes what it should, and bounds hostile uploads (2026-09-26)

**Repo:** EDDI (`fix/rag-ingestion`)

A review of RAG ingestion (findings R1–R10, U3–U6 and four new ones) found ways for a knowledge base to lose documents that still exist, keep answering from documents that are gone, and be taken down by one uploaded file. This fixes them. Every behaviour below is documented in [`rag.md` → Ingestion Sources](/agent-configuration/rag.md#ingestion-sources).

### Crawling

* **R1 — an unchanged parent no longer deletes its children.** A 304 has no body and so no links; the children of a revalidated page were never reached, the crawl still counted as complete, and after `tombstoneAfterMissedRuns` runs every page below it lost its vectors. Validators are now sent only for pages at `maxDepth`, whose links are not followed anyway. Pages above it are downloaded in full; the content hash still decides re-embedding, so this costs downloads, never embeddings. (`WebCrawler.processCandidate`)
* **Stale ETags are refreshed.** A page downloaded again and unchanged now stores the validators of that response (`IIngestionStateStore.recordSeen(…, etag, lastModified)`, implemented in the Mongo, PostgreSQL and in-memory stores). The first ingest's ETag used to be kept for ever, so a server that rotated it never answered 304 again.
* **R9 — canonical links are de-duplication, not identity.** A page is stored under the URL that served it. A page naming another in-scope, same-host page as canonical defers to it (that page is fetched and stored with its own content) and is stored itself only if the target produced nothing. A deferring or duplicate page's links are still followed — a paginated listing no longer loses its later entries. Sitemap discovery now follows `<sitemapindex>` files and falls back to `/sitemap.xml` when robots.txt names none or is not read (unless robots.txt disallows it). **Upgrade notes:** a page that declared a different canonical was stored under the canonical's id before; it is now stored under its own URL, so such pages are re-embedded once and the old ids reconcile away over the next `tombstoneAfterMissedRuns` complete runs. Sitemap pages are queued at depth 0, so a source that stayed under `maxPages` by `maxDepth` alone can now reach the page limit — which disables deletion reconciliation for that run; the crawler logs a WARN naming the sitemap when that happens.

### Runs

* **R2 — scheduled crawls are no longer cancelled at the lease.** The fire ran the crawl on the scheduler's thread, which cancels a fire after its 5-minute lease while a crawl's default budget is 10 minutes. `RagSourceIngestionService.processScheduledFire` now claims the run and starts it on its own worker (new `IngestionReport.Outcome.STARTED`), as a manual run does. When that run later fails, the worker writes a second, `FAILED` fire-log entry for the fire (it does not raise the schedule's `failCount`, so a failing crawl neither retries early nor dead-letters — documented in [`scheduling.md`](/conversations-and-orchestration/scheduling.md#fire-logging)). On a graceful shutdown the runs this instance was carrying are closed as `CANCELLED` and their workers interrupted, so a rolling restart no longer blocks "Run now" for the \~25 minutes until they would have been reaped.
* **A run that loses its source stops.** The collector asks whether its run is still the active one — before every embedding, and every 5 s between pages. A run whose row was purged (source removed, knowledge base renamed, state purged) or that was reaped used to carry on writing chunks and state rows for a cleared source.
* **Removal of an upload source mid-run / rename mid-run.** After every run the worker re-reads the knowledge base (`cleanUpAfterRun`): if the source is gone or changed type, what it wrote after the removal is removed again; if the knowledge base was renamed, the source's state is purged again under the run claim. A store that cannot be read is never mistaken for a deleted knowledge base.
* **R10 — ingestion crons honour `eddi.schedule.min-interval-seconds`.** Refused with a 400 at save time (`requireAllowedIntervals`), and skipped with an error by `syncSchedules` for a writer that bypassed the check (ZIP import). An existing too-frequent schedule keeps firing until its knowledge base is next saved — and that save is then refused until the cron is fixed.

### Removing and purging

* **R3 — removing a web source removes its vectors.** `discardRemovedSources` skipped crawl sources on the theory their documents "come back on the next run"; a removed source has no next run. Any source removed or changed in type now has its chunks removed and its state forgotten; deleting the last version of a knowledge base does the same for every source.
* **R7 — purge is decided under the run claim.** `purge` takes the maintenance claim and returns `false` (→ 409) while a run holds the source, instead of a check-then-act in the REST layer. The rename path uses the new `forgetStateAfterRename`, which purges regardless (the run then stops).
* **RAG backend purge claim — refuted as worded, fixed at the root.** The API and Manager say a purge "does not by itself remove vectors" and the next run re-ingests, which was true for documents that still exist. It orphaned the rest: a document gone from the source before the purge was never found again, and with its state row gone nothing reconciled it. The first run after a purge (one that starts from no state) now records a marker row (`IngestionPipeline.SWEEP_MARKER`) that no crawl ever sees. It is missed by every complete run like a vanished page, and once missed `tombstoneAfterMissedRuns` times, a failure-free run removes every chunk of the source not written by a run since the purge (`sweepOrphans`, filter `runId NOT IN` the post-purge run history). A page only briefly absent after a purge thus gets the usual grace. A failure reading the state at run start now skips the marker, not the run. The documented contract — chunks keep answering until rebuilt — is unchanged.
* **R4 — a failed chunk removal keeps the file.** `forgetDocument` now returns `ForgetOutcome` (`REMOVED` / `UNSUPPORTED` / `FAILED`). On `FAILED` the file is kept and the delete answers `503` (`DeleteOutcome.REMOVAL_FAILED`); deleting it anyway stranded the chunks for good.
* **U6 — file deletes no longer pose as runs.** The maintenance claim is released with the new `IngestionRun.Status.MAINTENANCE`, which `listRuns` omits in all three stores. The row is kept, not deleted, because its generation must stay counted: deleting it let the next run reuse the generation and be fenced out of its own documents (pinned by a contract test). Both stores now read statuses through `Status.parse`, which maps one this build does not know to `FAILED`. **Rolling upgrade:** a node built before this branch still throws on `MAINTENANCE` in `listRuns` (500) until it is upgraded; ingestion is unreleased, so only snapshot deployments see it.

### Uploaded files

* **R5 — PDF extraction is bounded, by counting what PDFBox decodes.** The first version of this fix measured streams on the raw bytes only, and review showed four ways past it: spaces after the `stream` keyword, non-Flate filters (ASCIIHex/ASCII85/RunLength/LZW, alone or chained), one stream referenced many times from a page's `/Contents` (an OOM at `-Xmx1g` from a 61 KB file), and encrypted streams. PDFBox's `MemoryUsageSetting` cannot close these: `Filter.decode` writes into a buffer it sizes itself and never consults the stream cache (checked in the 3.0.8 bytecode). So `PdfDecodeBudget` wraps every filter in PDFBox's `FilterFactory`, once, in one that counts its output against a budget armed only on the extracting thread; the count covers every stream, chain stage, repeated reference and decrypted stream, and past `ExtractionLimits.maxDecodedBytes()` (2 × `maxUncompressedBytes`, 128 MB) the document is refused — also when PDFBox swallowed the failure, because the flag stays set. If the filters cannot be wrapped, PDFs are refused, not read unbounded. `PdfStreamBudget` stays as a cheap pre-filter: it now finds the stream start as PDFBox does, decodes the declared filter chain with PDFBox's own filters, and skips image streams and image codecs, which text extraction never decodes (so a large image no longer refuses a PDF). Every bypass above was re-run with the reviewer's probe at `-Xmx1g`, with the pre-filter on and off: all refused. The per-page deadline (60 s) and glyph cap stay; the upload-time probe now gets 10 s.
* **R6 — an unreadable replacement retires the old text.** A stored file that cannot be read, or yields no text, is now a definitive failure for an upload source: the previous version's chunks are removed and its row tombstoned. It used to be recorded as "could not look", so the replaced document kept answering indefinitely. A file-store read failure is still treated as transient.
* **NEW — scanned PDFs are refused at upload, with the reason.** The upload now runs the extraction (stopped after 64 characters) and refuses a file with no text — "This PDF has no text layer… EDDI has no OCR". They used to be stored, listed as not indexed, and silently skipped by every run.
* **U3 — the extension no longer vouches for a binary format.** Text content under a `.pdf`, `.docx`, `.xlsx` or `.pptx` name is refused at upload, because it does not have the format that extension claims, instead of being stored as that format and failing every run.
* **U4 — size is checked before bytes are read, and the 60 MB body limit is scoped to the upload.** The REST layer passes each part's declared size and the service refuses an oversized file without reading it. The new `RequestBodyLimitGuard` holds every other endpoint to `eddi.http.limits.default-max-body-size` (default `25M`), raised automatically to fit an attachment at `eddi.attachments.max-size-bytes` base64-encoded (4/3 + 1 MB, about 27.7 MB for the default 20 MiB), so inline attachments and operators raising the attachment limit are not refused. A chunked HTTP/1.1 body (no `Content-Length`) is refused with 411 on every endpoint but the upload — counting bytes as they arrive is not possible from a Vert.x filter, because the REST layer replaces the request's data handler; `eddi.http.limits.refuse-unsized-bodies` turns that off. An HTTP/2 request without a `content-length` is left to the global ceiling: a filter cannot tell it from a request with no body. HTTP/1.x refusals send `Connection: close`; HTTP/2 ones do not, since HTTP/2 forbids the header. The upload exemption is matched below `quarkus.http.root-path`. A ZIP import gets no exemption: it is held to the same effective limit as every other endpoint (`25M` by default, raised to fit the attachment allowance, capped at `quarkus.http.limits.max-body-size`), no longer to the old global 60M.
* **U5 — concurrent uploads cannot overshoot the limits.** Each file is measured against a fresh listing under a per-source (striped) lock on this instance; across instances a new file that finds the source over its limit once stored is deleted again — but only if the stored file still has the content hash written here, so another instance's same-named upload is never deleted. Two instances adding different files to a source with one slot left can both see it full and both refuse; the uploader retries. Identical bytes under one name, from two instances, into a full source, in the same instant, remain indistinguishable.

### Review follow-ups

* **A dense first page no longer reads as a scan.** A page stopped at its glyph allowance dropped the text it had laid out. The upload probe asks for 64 characters, so the first page may lay out about 20,000 glyphs; a denser one (a small-font table, a large drawing) came back empty and the upload was refused as "no text layer". `PdfTextExtractor`'s stripper now finishes such a page on what it collected (`writePage` on the glyphs gathered before `PageTooLong`) and stops there.
* **One dead link no longer blocks the orphan sweep for good.** `sweepOrphans` waited for a run with no failure, and `failed` counts 404/410s and files with no text — definitive answers after which no old chunk needs keeping. It now waits on the new `Collector.inconclusive`: content-unknown crawl errors, embedding failures, and failed removals of an unreadable file's old chunks.
* **A failure settling a source after a run still reports the run.** `cleanUpAfterRun` has its own guard in the worker's `finally`, so `onFinished` — and with it a scheduled run's `FAILED` fire-log entry — always runs.
* **`RequestBodyLimitGuard` broke agent sync (Integration Tests: `AgentSyncIT`, five failures, 502).** The JDK client sends its GETs to the other instance over h2c. The guard read "HTTP/2 and not yet ended" as a body without a length, but a bodyless GET is not ended yet when a filter runs either, so it answered 411 — with `Connection: close`, which HTTP/2 forbids, so the client discarded the response as malformed. The HTTP/2 inference is gone, and the header is sent on HTTP/1.x only.
* **The guard's attachment allowance is capped by the HTTP ceiling (CodeRabbit).** Raising `eddi.attachments.max-size-bytes` raised the guard's limit past `quarkus.http.limits.max-body-size` (60M), which refuses the request first — a 60 MiB attachment needs about 81 MB base64-encoded — so the docs' "the two settings cannot disagree" was wrong past about 44 MB. `RequestBodyLimitGuard` now caps its limit at that ceiling and, at startup, logs a WARN naming both properties and the ceiling needed (not a startup failure: multipart attachment uploads, which are not base64, still fit). Documented in `application.properties`, `configuration-reference.md` and `attachments-guide.md`.
* **A rename during a run no longer leaves pages a newer run calls "unchanged" (CodeRabbit).** The rename's purge frees the source, so a new run can claim it while the old one is between its ownership check and recording a page it just embedded into the old store; that insert has no row to be fenced by, the new run reads its hash as unchanged, and the page never reaches the store the new name addresses. `cleanUpAfterRun` then found its purge refused (the new run holds the source) and ignored that. It now calls the new `IIngestionStateStore.invalidateContent`, which nulls every row's content hash, ETag and Last-Modified for the source — unfenced, since the other run owns the rows, and not a tombstone, which would stop that run from ever removing vectors it did write — and logs a WARN. Implemented in the Mongo, PostgreSQL and in-memory stores and pinned by a contract test.

### Not in this branch

* **R8** (redirect bodies in `SafeHttpClient`) belongs to `fix/outbound-http-hardening`, which closes them (`discardingRedirectBodies`); not duplicated here.
* The Manager's purge dialog still says "Documents already stored are not removed by this", which remains true; no UI change is needed. The Manager does not yet show the `503` delete message or the `STARTED` fire outcome specially — both are backward compatible (a 5xx is already an error there).

### Residual risk, stated

* The PDF decode budget counts what PDFBox writes through its filters. Memory PDFBox takes outside them (object parsing, fonts it builds from decoded bytes, the page's glyph list — capped separately) is not counted by it; the deadline and glyph cap bound those.
* The budget relies on replacing entries in PDFBox's `FilterFactory` by reflection. A PDFBox upgrade that renames that field makes every PDF refused (loudly, with an ERROR at first use), never read unbounded; `DocumentExtractorsTest` fails in that case.
* A chunked body is refused rather than counted; a client that cannot send a length needs `eddi.http.limits.refuse-unsized-bodies=false`, which leaves it to the global 60 MB. An HTTP/2 body without a `content-length` is always bounded by that global 60 MB alone.
* A run in flight when its source is removed can write one more document between the purge and its next ownership check; `cleanUpAfterRun` removes it once the run ends.

**Files:** [`WebCrawler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/crawl/WebCrawler.java), [`IngestionPipeline.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/IngestionPipeline.java), [`RagSourceIngestionService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/RagSourceIngestionService.java), [`IIngestionStateStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/IIngestionStateStore.java) and its Mongo/PostgreSQL stores, [`IngestedFileService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/files/IngestedFileService.java), [`PdfTextExtractor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/extract/PdfTextExtractor.java), [`PdfStreamBudget.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/extract/PdfStreamBudget.java), [`PdfDecodeBudget.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/extract/PdfDecodeBudget.java), [`DocumentExtractors.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/ingestion/extract/DocumentExtractors.java), [`RestRagIngestion.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/rag/rest/RestRagIngestion.java), [`RestRagStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/rag/rest/RestRagStore.java), [`RequestBodyLimitGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/RequestBodyLimitGuard.java), [`ScheduleFireExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java).

***

## 🔒 fix(engine): teardown, cleanup, delegation and recruitment bound to what the caller owns; reserved context keys read exactly (2026-09-26)

**Repo:** EDDI (`fix/reserved-context-keys`, PR #831)

Review findings C3a/C3b/C3c/C6/M-T2/M-A1 on the dynamic-agent tools and the context keys the engine trusts. #859 (C3a) and #860 (C6) fixed them independently on `main` while this branch was open — see the reconciliation entry below for what was kept from each side. What this branch adds on top of `main`:

* **C3b — teardown of any agent.** `teardown_agent(victim, delete=true)` decided from the conversation's created-agents list alone. **Fixed** with a creator marker: new `AgentConfiguration.dynamicOrigin` (`createdByAgentId`, `createdInConversationId`, `createdInGroupConversationId`, `createdForUserId`), stamped on v1 by a new internal overload `AgentSetupService.setupAgent(request, origin)` — not a `SetupAgentRequest` field, which is a REST/MCP body. [`TeardownAgentTool`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/TeardownAgentTool.java) refuses, for undeploy as well as delete, unless the agent's current config carries a marker naming this conversation or its discussion; unreadable config fails closed. [`GroupLifecycleOps.cleanupEphemeralAgents`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupLifecycleOps.java) applies the same marker: names this discussion → policy in full; names another → left alone; none or unreadable → undeploy only, never delete. The marker is engine-owned on every write path: [`RestAgentStore.updateAgent`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/agents/rest/RestAgentStore.java) keeps the stored value whatever the body says (a store failure on that read fails the update), and `duplicateAgent` and a ZIP import that creates a new agent drop it.
* **M-A1 — recruitment and delegation unchecked.** New `ResourceAccessGuard.principalMayUse(resourceId, principal[, principalIsAdmin])`, a request-free USE check (own resources, direct grants, published; team shares refused because the member turn carries no token; admin only via `CallerIdentity.isAdminActingAs`, from the new `CallerIdentity.admin`). `recruit_agent` asks it for the discussion owner **before** the deployment check, so a refusal never reveals whether an off-limits agent is deployed. `converse_with_agent` asks it for the bound user on every delegation, new or continued; agents this conversation or its discussion created are exempt only when tracked **and** carrying a matching `dynamicOrigin`.
* **M-T2 — retain lasted one turn.** The retained set is seeded from the *latest* recorded set (not the union, so `unretain_agent` is not resurrected), narrowed to agents still tracked as created.
* **Reserved keys read exactly (CWE-863, CodeRabbit on this PR).** `IConversationStep.getLatestData` / `IConversationStepStack.getAllLatestData` match by **prefix**, so a client key such as `groupIdSuffix` or `dynamicAgentConfigX` — not reserved by name — was read as the reserved key. Every reserved-key reader now uses `getData` or the new `IConversationStepStack.getExactDataPerStep` (`ConversationGroups`, `DynamicAgentToolsProvider` policy/created-list/delegation-depth, `AgentOrchestrator.groupConversationIdOf`), and [`ClientContextGuard`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/ClientContextGuard.java) also drops any key that starts with a reserved name (`shadowsReserved`). Keys extending an operator-permitted reserved key stay permitted. Documented in [`passing-context-information.md`](/agent-configuration/passing-context-information.md#reserved-context-keys).
* **C3c — an earlier step's `groupId` is verified.** [`ConversationGroups.resolveGroupIds`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationGroups.java) trusts the current step's `groupId`, but an earlier step's only when that step's `groupConversationId` names a running discussion that has this conversation as a member and belongs to that group (`LiveDiscussionRegistry.isLiveMember`). Applied on all three paths — the `usermemory` tool, the `longTerm` write boundary (`IPropertiesHandler.getGroupMembershipCheck`) and undo/redo sync — so a `groupId` forged before the guard existed no longer keeps a client in another team's group memory.

### Design decisions

* Belt and braces for teardown: the context guard closes today's hole; the marker keeps a future leak of any list from reaching an agent a person built.
* Compatibility: `dynamicOrigin` is additive (strict-boundary round-trip test passes). Sub-agents created before this change carry no marker: `teardown_agent` refuses them and end-of-discussion cleanup only undeploys them. `CallerIdentity` gained a trailing `admin` component; the 3- and 4-arg constructors remain (non-admin). A client can no longer use a context key that starts with a reserved name (`groupIdLabel`). A turn the owner sends into a member conversation after its discussion ended — or on another node — persists group-visible properties as `self`.

### Tests

`ClientContextGuardTest` (prefix drop, permitted extensions), `ClientContextGuardSourceTest` (`noPrefixLookupOfAContextKey`, `everyEngineWrittenLiteralKeyIsReserved`), `ReservedContextKeyExactLookupTest` (real `ConversationMemory`, 8 cases), `ContextualToolsProviderGroupIdTest` and `ConversationLongTermPersistenceTest` (earlier-step verification), `DynamicAgentCrossTurnStateTest`, `ConverseWithAgentToolOwnershipTest` (3 USE cases), `RestAgentStoreTest$DynamicOriginIsEngineOwned`, `GroupLifecycleOpsTest`, `DynamicAgentToolsTest`, `RecruitAgentToolTest`, `ResourceAccessGuardTest`, `CallerIdentityContextTest`. Mutation-checked: prefix readers, exact-name guard and unverified earlier steps each fail their tests.

***

## 🔒 fix(secrets): secret scope on every path, no echoed plaintext (2026-09-26)

**Repo:** EDDI (`fix/secret-scope-vault`)

Fixes the secret-handling findings of the 2026-09-25 whole-repo review (C2, C2b, H8/S3, S2, S4, S5, M-S1), and the follow-up pre-push review of this branch.

> **Reconciled with main on 2026-09-29** — main fixed C2 in parallel, and its implementation was kept. The items marked *superseded* below are no longer part of this branch; see the reconciliation entry that follows.

### What changed and why

* **C2 — one vault entry per conversation.** *Superseded by main's per-write slots* (`AutoVaultedSecrets`). What remains from this item:
  * The writer is a new bean, [`SecretPropertyVault`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/properties/impl/SecretPropertyVault.java). Main's vaulting and step-scrub code moved there from `PropertySetterTask` unchanged, so the other property paths share it.
* **A new conversation gets its id before its first turn (review #1).** *Superseded*: main's slot name needs no conversation id.
* **Vault entries are deleted with their conversation (review #3).** *Superseded* by main's deletion on permanent delete, the retention sweep and GDPR erasure.
* **C2b — secret scope on every property path, or fail closed.**
  * These paths used to store the value as a plaintext property whatever the scope: the `fromObjectPath` branch and the typed value fields of the property setter, and every pre-request / post-response instruction (`PrePostUtils`: httpcalls, MCP calls, LLM tasks). Strings are now vaulted on all of them.
  * A value that turns out not to be a string at run time fails the turn.
  * In `PrePostUtils` that failure is `SecretPropertyVault.SecretPropertyException`. It is rethrown past the loop's log-and-skip handling (review #5): silently storing nothing left `{properties.x}` empty and produced a 401 on the next call that named nothing.
  * Statically impossible combinations are rejected when the configuration is saved by [`SecretScopeValidation`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/SecretScopeValidation.java). It runs from the `validate` hook of `PropertySetterStore`, `ApiCallsStore`, `McpCallsStore` and `LlmStore`, and rejects typed values and names containing `/`, braces or `$`.
  * Two cases are no longer rejected on save, because they work at run time (review nit #10): `convertToObject: true`, which only converts values shaped like a JSON object, and names containing whitespace.
* **H8 / S3 — data-supplied `${vault:}` in LLM parameters.**
  * `ChatModelRegistry` resolves `${vars:}` and `${vault:}` in every builder parameter after templating. A parameter such as `"modelName": "{context.model}"` therefore resolved any secret a user typed into it, with no grant check.
  * The new shared helper `ConfigReferenceGuard.requireConfiguredParameters` applies the httpcall rule: a reference is resolved only where the configured template wrote it, or where the named property is this conversation's own auto-vaulted secret. A data-supplied `${vars:}` is refused as well.
  * The helper covers the task parameters (`LlmTask`) and, per review #2, the cascade step and judge parameters (`CascadingModelExecutor`).
  * A judge refusal fails the turn rather than falling back to the heuristic.
  * `systemMessage` and `prompt` are never resolved, so they are exempt.
  * The false comment at `LlmTask.escapeConfigReferenceMentions` is corrected.
* **S2 — responses echoing a substituted secret.**
  * The executor records every plaintext it substitutes. Per review #7, that now includes connection credentials, with and without their scheme, and `${caller:token}`, not only vault secrets.
  * These plaintexts are removed from the error body, the status message and the response header values, and from the INFO response log. The log goes through `RequestRedactor.safeResponseLog`, which also masks credential-named headers and `Set-Cookie`.
  * A success body is data, so it is redacted without mangling it (review #8):
    * A secret of 8 characters or more is removed from the text.
    * A JSON body is parsed first and scrubbed as a tree: shorter secrets only where a value *is* the secret, and numbers only whole, never digit by digit.
  * A token captured by a post-response `scope: secret` instruction is also removed from the map returned as the LLM tool result (review #6). `PrePostUtils.runPostResponse` now returns the plaintexts it vaulted.
* **S5 — `URI.create` message leak.**
  * The request path is resolved before it is parsed, and `URI.create` quotes the whole string it rejects. A secret in a URL path therefore reached the ERROR log and the task-error digest.
  * `buildRequest` and `execute`'s catch-all now redact any exception whose message chain carries a substituted plaintext, and drop the cause chain.
* **S4 — short secret context values.**
  * Values under 8 characters were only removed from their own context entry.
  * A context entry whose whole value is a 4–7 character string is now also replaced wherever a property, datum, list element, map value, number property or audit-payload field *is* that value. The match is exact, never a substring.
  * Per review #4, the leaves of a secret *object* are never exact-matched. Its `"tokenType": "Bearer"` or `"port": 8080` would otherwise blank every equal value of the turn, loaded `longTerm` properties included, and persist that loss.
* **A legacy shared-key reference gets its own message (review #9).** *Superseded*: main still accepts a legacy `<agentId>.<property>` reference in an older conversation.
* **M-S1 — setup plaintext.**
  * `AgentSetupService.vaultApiKey` used to fall back to plaintext when the vault was configured but the write failed. It now fails the setup.
  * A plaintext `apiAuth` in `createApiAgent` is vaulted and recorded for rollback, and the spec is re-parsed so the generated headers carry the reference.
* **CodeRabbit full review of `b4be2a61d`.**
  * *Orphaned vault entries.* *Superseded*, with the per-conversation key the sweep depended on.
  * *Short secrets inside stored JSON text.* A paused tool call keeps its arguments as a JSON string, so `{"pin":"4711"}` did not equal `4711` and the exact match missed it. With exact-match values in play, `SecretValueScrubber` now parses a string that holds a JSON object or array, scrubs the parsed tree and writes it back. It does this only when something was replaced. Nested serialized JSON, such as a transcript's tool-call arguments, is reached by the same walk.
  * *Map keys.* *Superseded*: main never renames a map key for a short secret, so exact matching replaces values only.
  * *Checked template failures.* `buildRequest` now redacts a `TemplateEngineException` as well as a `RuntimeException`. The first can follow a path that already resolved a secret, and before this fix it reached the caller before those plaintexts joined its redaction set.
  * *`${vars:}` parameter check.* The check now uses set membership of whole references, as the credential check does, instead of substring containment.
  * Two findings were not changed. The leaves of a secret *object* are still not exact-matched: that is decision review #4, and the docs now tell callers to send a short credential as its own string entry. `conversationIdOf` also keeps its segment match: the description, which only `vault` writes, already names the conversation.

### Behaviour changes to know about

* **Default install (vault disabled):** every `scope: "secret"` instruction fails its turn with an error naming `EDDI_VAULT_MASTER_KEY`. That includes httpcall, MCP and LLM post-response instructions, which used to store the value in plaintext.
* Saving a configuration returns 400 for a `scope: "secret"` instruction that sets `valueObject` / `valueList` / `valueInt` / `valueFloat` / `valueBoolean`, or a literal name containing `/`, braces or `$`.
* An LLM task, cascade step or judge parameter that carries a `${vault:…}`, `${connection:…}`, `${caller:…}` or `${vars:…}` reference its configured template did not write fails the turn.
* A configured but failing vault now fails `setupAgent` / `createApiAgent`.

### Deferred / follow-up

* The HITL-resume rebuild in `LlmTask` goes through the same `runTemplateEngineOnParams`, and so through the same guard, as the normal path. It is not driven by a dedicated test.

***

## 🔒 fix(hitl): bind a decision to its pause; scope the approver's identity to what they approved (2026-09-26)

**Repo:** EDDI (`fix/slack-hitl-binding`)

### What changed and why

Fixes the engine-side HITL findings of the 2026-09-25 review (H4b, H5, A2). The Slack findings of the same review (H4a, H4c, H4d, the Slack user-id collision) were fixed on `main` by `fix/security-channel-identity` (#861); see the reconciliation entry below for what this branch still adds on the Slack side.

* **H4b — a decision is bound to the pause it was made for.** New optional `HitlDecision.pauseId` (the pause start's epoch milliseconds, `HitlDecision.pauseIdOf`), reported as `pauseId` by conversation and group `approval-status` (REST and MCP). `ConversationHitlService.resumeConversation` refuses a mismatching decision before the CAS, and re-checks after it against the snapshot the resume runs on (restoring the pause if it changed in between) — `IConversationService.PauseMismatchException`, REST `409`, MCP `errorCode: PAUSE_CHANGED`. The pre-CAS comparison runs only while the conversation is `AWAITING_HUMAN`: an already-resolved conversation gets the ordinary state conflict (`409` naming the state, MCP `WRONG_STATE`). `GroupHitlCoordinator.resumeDiscussion` checks it against `pausedAt` and throws `IGroupConversationService.GroupPauseMismatchException` (a `GroupDiscussionException`, so REST still answers `409`); the MCP `approve_group_phase` tool takes an optional `pauseId` argument like `resume_conversation` and answers `PAUSE_CHANGED`.
* **H5 — the approver's identity covers what the approver saw.** A resume bound the approver as the caller for the whole resumed turn, so every later `${caller:token}` call — including all calls after a RULE pause, which previews nothing — went out with an admin's token. Now: approver == conversation owner → unchanged. Otherwise the turn runs with **no caller** (caller-bound calls fail closed, like a scheduled turn), and the approver is bound on a separate `CallerIdentityContext` approver slot that `ToolLoopResumer` promotes to the caller only around each explicitly approved tool call (`callAsApprover`). `propagate()` carries the new slot. A resumed group discussion runs as the approver only when the approver started it (`GroupHitlCoordinator.resumeCallerFor`).
* **A2 — the approver view drops the batch fingerprint.** `sanitizePendingToolCallsForApprover` now nulls `PendingToolCallBatch.fingerprint`, an unsalted SHA-256 over tool names and raw arguments (an offline guessing oracle next to the redacted arguments). It is still persisted for the no-progress guard.

### Compatibility

* **Behaviour change (intended):** a HITL resume by someone other than the conversation owner no longer carries a caller identity outside the approved tool calls.
* REST/MCP shapes are additive: `pauseId` on the decision body, the `approval-status` summaries and as an optional argument of the MCP `resume_conversation` and `approve_group_phase` tools (an additive MCP tool-schema change). Omitting `pauseId` keeps the previous behaviour.

### Design decisions

* Pause identity is the pause start timestamp in epoch milliseconds, not a new stored field: every pause stamps a fresh `hitlPausedAt` / `pausedAt`, Mongo keeps milliseconds, and REST already exposes `pausedAt` — no migration. It is the same value `main`'s Slack approval records use as their pause epoch.

**Files:** [`ConversationHitlService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/ConversationHitlService.java), [`CallerIdentityContext.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/CallerIdentityContext.java), [`ToolLoopResumer.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ToolLoopResumer.java), [`GroupHitlCoordinator.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java), [`HitlDecision.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/lifecycle/model/HitlDecision.java), [`McpHitlTools.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpHitlTools.java), [`ConversationMemoryUtilities.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java); docs [`hitl.md`](/conversations-and-orchestration/hitl.md), [`mcp-server.md`](/protocols-and-integration/mcp-server.md).

### Follow-ups

* The group approve path still reads, checks and CAS-writes on state only; the pause-id check there has a read-to-CAS window until the group conversation gets a state-and-version CAS.
* `GroupHitlCoordinator.submitHumanInput` binds the submitting human member for the rest of the discussion — the same class as H5, not in this finding's scope.
* The Manager approvals UI should pass `pauseId` from `approval-status` into its resume call (backend accepts it; omitted keeps the old behaviour).

***

## 🔐 fix(vault): key-material races, re-runnable KEK rotation, independent audit key (2026-09-26)

**Repo:** EDDI (`fix/vault-key-safety`)

### What changed and why

Findings from the 2026-09-25 code review, each with a regression test.

* **H6a — salt race and legacy fallback.** `VaultSaltManager` created the per-deployment salt with an unconditional upsert, so two replicas booting against an empty database each kept their own salt and the loser's DEKs became unreadable at its next restart; a transient read error fell back to the legacy salt even on deployments that already had a random one. The salt is now written with `ISecretPersistence.putMetaValueIfAbsent` (Mongo `findOneAndUpdate` + `$setOnInsert`, Postgres `ON CONFLICT DO NOTHING` + read-back) and the loser adopts the winner; the meta store is re-read after listing DEKs so a salt written in between is not mistaken for legacy; and a salt that cannot be read **fails the start** instead of falling back.
* **H6b — first DEK race.** A tenant's first DEK was written with `upsertDek`, so concurrent first stores replaced each other's generation 1 and lost a secret. It is now `insertDek`; the loser reads back and uses the winner's key.
* **H6c — KEK rotation.** `rotateKek` is ordered so a retry with the same two keys completes it: a legacy-salt migration persists its new salt as *pending* before any DEK is wrapped under it (it lived only in memory, so a half-way failure left DEKs unrecoverable); phase 1 accepts DEKs that open with the old **or** the new KEK (so the documented retry no longer fails); a KEK check value is switched to the new KEK before the first re-wrap, and every node checks it before wrapping a new DEK, so a replica still on the retired master key refuses instead of stranding a tenant; re-wraps are guarded on the IV they read (`updateDekWrapping`), followed by a bounded catch-up sweep; the pending salt is promoted last. A node restarted mid-migration with the new key opens DEKs under either KEK and wraps new ones under the announced one.
* **H6d — audit HMAC key.** New `AuditKeyring`. The signing key is `eddi.audit.hmac-key` if set, otherwise the key *pinned in the vault* (the master-derived key stored sealed, insert-if-absent, on first start — it survives KEK rotation because the vault re-wraps its DEKs), otherwise the master-derived key. New entries are signed as **v5** `v5:<keyId>:<hex>`; verification uses the named key, tries every known key for pre-v5 rows, and reports a key it does not hold as the new status `UNKNOWN_KEY` (counted with `invalid`, listed separately in the problem list) instead of as tampering. Retired keys go in `eddi.audit.hmac-previous-keys`.
* **L-S2 — keyed pseudonym.** v5 signs, and GDPR erasure now writes into v5 rows, `gdpr-erased:k1:<HMAC(key, userId)>` instead of the unsalted `sha256`. Both audit stores pseudonymise v5 rows per signing key first, then everything else with the caller's pseudonym as before. **Partial:** v1–v4 rows keep the unkeyed form (their signatures cover it) and so do the database logs, whose pseudonym comes from `GdprComplianceService` — owned by the GDPR branch; follow-up.
* **L-S1 — AAD.** New secrets are sealed with AES-GCM associated data naming tenant and key, and new DEK wrappings with tenant and generation (`a1:` prefix). Unprefixed ciphertext keeps decrypting without AAD, so nothing is migrated; DEK rotation re-seals secrets into the bound form and KEK rotation re-wraps DEKs into it. OAuth connection grants sealed through `seal()` are not bound yet — follow-up, needs a row identity in `seal()`. System values are bound to their name (see M1 below).
* **L-S3 — tenant reset.** `SealedDataRotationParticipant.discardAll` is called before the DEKs are deleted; `ConnectionGrantResealer` deletes the tenant's OAuth grants (which would otherwise fail GCM on every request because the next DEK reuses the dekId). A participant failure stops the reset with the DEKs in place. The reserved system tenant `__eddi-system` cannot be reset.
* **S1 (backend half) — value rotation reset the grant.** `store()` passed `["*"]` and a null description down as a whole-row write. A null grant/description now means "not supplied": both stores keep the stored values on update and default only on insert, which also removes the lost update against a concurrent grant edit.
* **S6 — grant precondition.** `PUT …/grant` accepts optional `expectedAllowedAgents`; the write is conditional in the store (Mongo `$expr`/`$setEquals`, Postgres `@>`/`<@`), and a mismatch is **409** with the current grant (dry runs too). Omitted, behaviour is unchanged.
* **S7 — impact analysis.** `VaultGrantChecker.checkReferences` returns `REFERENCES` / `DOES_NOT_REFERENCE` / `UNKNOWN`; an unreadable agent, workflow, extension config or connection makes the impact report `complete=false`.

### Pre-push review follow-ups

* **Lost master key (review B1).** The KEK check value that stops stale replicas cannot tell a lost key from a stale replica, so after a lost key every DEK creation was refused, and the documented "reset and start fresh" recovery could not work. New `POST /secretstore/secrets/admin/adopt-master-key?confirm=true` (`VaultSecretProvider.adoptCurrentMasterKey`) is the explicit operator decision. It re-announces the check with the configured key; resets the `__eddi-system` tenant and every `system-value:*` if they no longer open; and lists the tenants that need `/reset`. A vault holding no DEKs at all adopts the configured key at startup (the dev "restarted with another key" case). The decrypt-failure message names the endpoint. Documented under *Lost master key* in secrets-vault.md.
* **UNKNOWN\_KEY can no longer be forged (review M1).** Every key the audit keyring pins or signs with is recorded as a sealed system value, `audit-key-id:<id>`. System values are now sealed bound to their name, with AAD. A v5 row naming a key the deployment does not hold reports `UNKNOWN_KEY` only for a recorded id, and `INVALID` otherwise. The docs and Javadoc no longer call it "distinguishable from tampering".
* **Later rotation after an interrupted legacy migration (review m1).** `rotateKek` also tries the old key with the pending salt, and refuses with guidance when a DEK is still under the key before that. The wrong comment in `reconcileKekCheck` is corrected. Tests cover this path and the "salt promoted, pending marker not deleted" re-run.
* **Stale-replica TOCTOU (review m2).** After inserting a DEK (first DEK or DEK rotation), a node re-reads the check. If a rotation announced in between, it deletes the DEK it just inserted (`deleteDekIfWrappedWith`, guarded on the IV) before sealing anything.
* **Decrypt-failure message (review m3).** When this node's key is the vault's key, or a salt migration is pending, the message says to re-run `rotate-kek`. During a pending migration it no longer offers a reset.
* **Reset race (review m4).** Participants discard again after `deleteDek`.
* **Mongo grant precondition (review m5).** `$expr`/`$setEquals` replaces `$all`+`$size`, so a stored duplicate no longer makes every conditional edit 409.
* **Audit pin retry (review m6).** A failed pin is retried from `signingKey()` with backoff from 30 s to 10 min.
* **Hot path (review nit).** `AuditKeyring` uses a volatile fast path instead of a synchronized call per entry.

### Review follow-ups (CodeRabbit)

* **Refused legacy-salt rotation.** `rotateKek` reserved the pending salt before verifying, so a refusal (a wrong `oldMasterKey`, say) left it persisted and the deployment then reported an unfinished rotation at every boot and decrypt failure. A reservation the refused run created itself is now discarded (`VaultSaltManager.discardPendingSalt`); a pending salt left by an earlier interrupted run is kept.
* **Audit key-id record retry.** A failed `audit-key-id:<id>` write after a successful pin is retried from `signingKey()` under the pin backoff, instead of staying unrecorded until restart.
* **Impact analysis completeness.** A workflow or extension config read as `null`, and a `${vars:…}` that cannot be expanded, make `checkReferences` answer `UNKNOWN` rather than `DOES_NOT_REFERENCE`. The deploy gate (`findUngrantedReferences`) is unchanged.
* **Interrupted adoption.** `adoptCurrentMasterKey` also clears system values when the system tenant holds no DEK. An adoption interrupted between deleting the system DEKs and its second clear left a value no key can open, and a re-run skipped the cleanup, so every later pin failed.
* **Test.** `dekPersistenceFailure` now fails `insertDek` (the first DEK is never upserted) and asserts the write failure is the cause.

### Compatibility

* Stored data: nothing to migrate. Old ciphertext, DEK wrappings and v1–v4 audit rows keep working.
* REST: `GrantRequest` gains an optional field; `PUT …/grant` can answer 409 only when that field is sent. Audit verification can report `UNKNOWN_KEY`.
* New properties `eddi.audit.hmac-key` / `eddi.audit.hmac-previous-keys` (empty by default).
* Startup: an unreadable vault salt now fails the start (it used to derive a possibly wrong KEK).
* **When the vault holds DEKs, a master-key change blocks DEK creation** until a KEK rotation runs, or, if the old key is lost, until an admin calls `adopt-master-key`. A vault that holds no DEKs at all adopts the configured key at startup, so neither is needed there. Previously a node silently wrapped new DEKs under whatever key it had.
* New REST operation `POST /secretstore/secrets/admin/adopt-master-key`; the Manager's OpenAPI snapshot is updated.

**Files:** [`VaultSaltManager.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/crypto/VaultSaltManager.java), [`VaultSecretProvider.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java), [`EnvelopeCrypto.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/crypto/EnvelopeCrypto.java), [`AuditKeyring.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/audit/AuditKeyring.java), [`AuditHmac.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/audit/AuditHmac.java), [`VaultGrantChecker.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/VaultGrantChecker.java), [`secrets-vault.md`](/security-and-compliance/secrets-vault.md), [`audit-ledger.md`](/security-and-compliance/audit-ledger.md).

***

## 🔒 fix(security): workspace and owner scoping for groups, schedules, triggers, exports and listings (2026-09-26)

**Repo:** EDDI (`fix/workspace-authz-scoping`)

A batch of authorization gaps from the 2026-09-25 whole-repo review. Most only matter with `eddi.workspaces.enabled=true` (with enforcement off `ResourceAccessGuard.seesEverything()` is true and behaviour is unchanged); the items marked **always** apply whenever authentication is on.

### What changed, per finding

* **H1 — groups had no USE check.** `POST /groups/{id}/conversations` (plain and streaming), the continuation endpoints and the MCP `discuss_with_group`, `start_group_discussion` and `continue_group_discussion` tools now call `requireUseAccess(groupId, "group")`. A discussion fans out to every member at the owner's cost, and member turns run below the agent USE gate, so the group gate is the only one a borrower meets. Streaming refuses with a plain 403 before the stream opens; no event is written.
* **H2a + NEW (schedules).** A schedule that runs as a real user belongs to that user **(always)**: the rule `requireOwnUserId` already applied to update and fire now covers list, read, fire logs, delete, enable, disable, retry and dismiss, so an editor can no longer list, disable or delete another user's dream schedule. Shared (no `userId` or `system:scheduler`) schedules keep their old visibility with workspaces off; with them on, they are manageable by their creator and by whoever may EDIT what they drive (the agent, the RAG configuration of an ingestion schedule, the group of a cadence). The listing pushes this into the query (main's `ScheduleOwnerScope`, refined; both backends) for the same paging reason the HITL redaction lives there. `readSchedule` now hides HITL timeouts from non-admins like the listing does, and fire logs are visible exactly when their schedule is. `fireNow` rethrows `ForbiddenException` instead of turning the ingestion EDIT gate's 403 into a 500.
* **H2e — forged team cadences.** A create is refused when its body carries `teamCadenceType` (like the HITL and ingestion markers); an update may carry only the markers the stored cadence already has, so group editors can still change its cron, while adding or re-pointing them is refused. `fireNow` on a cadence needs EDIT on its group, and `TeamCadenceService.processScheduledFire` now takes the firing schedule's id and refuses unless it is the `Cadence.scheduleRef` the workspace registered, as a failure so the impostor dead-letters.
* **H2b — triggers.** The listing and single read show only triggers whose targets the caller may USE, and an unshown intent answers like an absent one. (Update/delete authority over the stored targets is main's USE gate — see the reconciliation below.)
* **H2c — export download.** Archive keys are `<slug>--<agentId>-<version>-<128-bit token>.zip`; the download parses the agent id back out and checks VIEW, like the export. The saved name (`Content-Disposition`) is still `<slug>-<agentId>-<version>.zip`. Archives written before this change (≤ 60 minutes old at upgrade) are no longer downloadable.
* **H2d — MCP backlog tools.** `add_team_task` needs EDIT and `list_team_backlog` VIEW on the group, matching `RestGroupWorkspace`.
* **H2f — parser.** `POST /parser/{id}` declares `eddi-admin`/`eddi-editor` (it had no roles, so any authenticated principal could call it) and checks VIEW on the parser configuration.
* **H2g + NEW — agent listings and descriptor redaction (redaction always).** `GET /administration/{env}/deploymentstatus`, and through it MCP `list_agents` and `discover_agents`, return only agents the caller may USE and run every descriptor through `redactUnlessOwner`, so grant lists and access indexes no longer leave the server to viewers. The channel router, which must see every agent, reads the new unscoped `IDeploymentStatusReader`.
* **H2h — `/v1` name oracle.** `AgentModelResolver.resolve` admits only agents the caller may use: a refused agent resolves exactly like an absent one, and is left out of the name/slug candidate sets before uniqueness is judged. That also gates reuse of an existing chat mapping, which is resolved first.
* **H3 — team memories by group id.** `GET /usermemorystore/memories/{userId}/visible?groupId=` and the MCP `get_visible_memories` tool require USE on every named group before recalling (`ResourceAccessGuard.requireUseAccessToEach`). The context-key path is branch `fix/reserved-context-keys`.
* **A1 — anonymous agent-card fan-out.** `getDefaultAgentCard`/`listA2AAgents` reuse their roster scan for 30 s (empty result included), so a flood of anonymous `/.well-known/agent.json` requests costs one scan per window instead of \~200 queries each.
* **A3.** The agent-card WARN log sanitizes the anonymous path parameter.
* **E5 — `SelfUrlResolver` ignored `quarkus.http.host`.** With the listener bound to one specific non-loopback address, the derived self URL is that address instead of `127.0.0.1`, which might be another process and would have received `${caller:token}`'s self release. Wildcard and localhost binds keep `127.0.0.1`; a specific loopback or IPv6 address is used verbatim.

### Pre-push review follow-ups

* **Failed-fires view.** `GET /schedulestore/schedules/admin/failed` stays complete for admins; for anyone else each entry is kept only if its schedule passes the same read rule as `/{id}/fires`. A page can therefore hold fewer entries than `limit`, which is documented.
* **Export and preview** leave out schedules that run as another user (admins export everything).
* **Team cadences belong to their group.** A cadence runs as its creator, but callers with VIEW on the group can read it and callers with EDIT can toggle or delete it. With workspaces off every cadence is listed (`ScheduleOwnerScope.withTeamCadences()`); under enforcement co-editors reach them by id or through the group workspace. Firing and re-pointing still act as the creator.
* **Refused trigger reads** throw `IRestAgentTriggerStore.TriggerNotVisibleException`, a subclass of the not-found exception, so HTTP clients still get a 404 while MCP `chat_managed` no longer deletes the caller's conversation mapping on a refusal.
* **Group approvals.** `approveGroupPhase` (plain and streaming) USE-gates a transcript owner who is neither admin nor approver, so access revoked during a pause cannot drive the run to the end.
* **`/v1`.** A refused exact id match stops at "unknown" instead of falling through to name/slug matching.
* **`/v1` model-id collisions.** Two ready agents with the same name slug and the same last six id characters used to share one model id, and only the first was kept: the other was never listed and could not be addressed by it, and once the first was refused a usable agent became unreachable. Every agent in such a collision is now listed as `<slug>-<full agentId>`, and the short id resolves only when the caller may use exactly one of them (ambiguous when both are usable, unknown when neither is). Nobody in a collision keeps the short id, because which one did would depend on listing order and a new namesake could take over conversations stored under it. Model ids of agents that do not collide are unchanged.
* **Failed exports leave no archive behind.** `ZipArchive` deletes the target when writing it fails, so a truncated ZIP no longer sits under its download key until the retention sweep.
* Streaming discuss refuses a missing USE grant with a plain 403 before the stream opens, the same contract `continueDiscussionStreaming` has for its ownership check (the review suggested the opposite direction; the existing continuation test documents the 403 contract).
* `ResourceAccessGuard.currentLevel` keeps VIEW for a resource with no descriptor: `VIEW.includes(USE)` holds, so it already agrees with `requireLegacyFallback`. A test now pins `hasAccess(USE)` for it.
* Testcontainers cases for the scoped listing in `datastore/mongo/MongoScheduleStoreTest` and `datastore/postgres/PostgresScheduleStoreTest`, plus docs in `scheduling.md`, `hitl.md`, `import-export-an-agent.md` and AGENTS.md.

### Design decisions

* `ResourceAccessGuard` gained `currentLevel`/`hasAccess` (non-throwing, current descriptor) for id-only listings, and `requireUseAccessToEach`.
* Schedule listing under enforcement without an `agentId` shows the caller's own schedules only; a team's shared system schedules appear when listing by an agent the caller may edit. "Agents the caller may edit" cannot be pushed into the schedule query, and filtering the page afterwards would bring back the short-page problem the HITL redaction was moved into the query to fix.

### Compatibility

* `ScheduleOwnerScope` (from main) gained `sharedOnlyIfCreatedByCaller()`/`withTeamCadences()`; its two-argument form keeps main's meaning.
* `TeamCadenceService.processScheduledFire(Map)` became `processScheduledFire(String, Map)`.
* Export download URLs changed shape (see H2c). `RemoteApiResourceSource` follows the `Location` header and is unaffected.
* Non-admin editors no longer see or manage other users' personal schedules even with workspaces off. `/parser` now needs an authoring role.

### Follow-ups

* `RestInterfaceFactory` still hard-codes `127.0.0.1:${quarkus.http.port}` for internal loopback calls (same E5 shape, no token release involved).
* The Manager saves an export under the URL's last segment, so the token appears in the saved file name; it could prefer `Content-Disposition`.

***

## 🔒 fix(security): a deleted conversation no longer opens to every caller; `/active` and `/end` gated (2026-09-26)

**Repo:** EDDI (`fix/conversation-access-guard`)

### What changed and why

**C1a: soft delete bypassed the conversation owner check.** `ConversationAccessGuard.requireConversationOwner` returned `null` ("allowed") whenever the live descriptor was missing. A soft delete (the default of `DELETE /conversationstore/conversations/{id}`, and what the Manager's delete does) archives the descriptor but keeps the memory snapshot, so from then on any authenticated caller could read the raw memory, run turns as the owner over `POST /agents/{id}` (longTerm memory written under the owner's id), use SSE, the tool history, the MCP conversation tools and the audit trail, and permanently delete the conversation.

* The guard now resolves the owner from the live descriptor and, if that is gone, from the archived copy (`readDescriptorWithHistory`). A soft-deleted conversation is owner-checked exactly like a live one.
* With no descriptor at all (live or archived), only an admin is let through (so an operator can still reach an orphaned snapshot or the audit trail of a deleted conversation). Everyone else gets a 404. With authorization disabled `isAdmin` is true, so nothing changes there.
* `requireExistingConversationOwner` resolves through the same method, so the two variants cannot drift.
* Soft delete now **ends** the conversation first, through `IConversationService.endConversation`, so a paused conversation's approval is resolved (attributed to the deleting caller, `system:delete` if there is no named one) and an in-flight turn does not write back. It also records ENDED on the descriptor before archiving it.
* **Conversations soft-deleted by earlier releases** are still READY. The conversation-id `say` and `sayStreaming` entry points (REST, SSE, MCP, Slack, `/v1`) now check for them: a conversation with no live descriptor but an archived one is ended on the first turn attempt and refused like any ended conversation. This is a lazy migration, at the cost of one descriptor read per turn on those entry points. The internal, agent-driven overloads (group members, schedules, A2A) are not checked.
* The retention sweep read "no live descriptor" as "orphan, delete now". Soft-deleted conversations now reach it as ENDED, so it checks the archived descriptor's age as well: they age out on the normal schedule, and an archive with no date is treated as expired. Aged-out soft deletes are counted in the sweep's total. Snapshots with no descriptor anywhere are still removed straight away (and not counted). **Retention effect:** a conversation ended and then soft-deleted used to be purged by the next daily sweep. It now stays until `deleteEndedConversationsOnceOlderThanDays` (default 365) has passed since its last interaction. Documented in `configuration-reference.md` and `gdpr-compliance.md`.
* **Stale managed-conversation mappings.** A permanently deleted or swept conversation leaves its intent→conversation mapping behind (only GDPR erasure removes it). The guard's new 404 escaped MCP `chat_managed` before the mapping was dropped, which failed every later call for that user. It now counts as a stale mapping: the mapping is deleted and a new conversation started. The REST twin (`RestAgentManagement.isConversationEnded`) failed on a missing conversation before this branch; it now recreates as well.
* The MCP conversation tools answer a guard 404 with `"Conversation not found"` and log it at debug. Before, it fell into the generic handler, which logged an ERROR with a stack trace for every probed id.

**NEW (audit trail readable after delete).** Audit entries are not deleted with a conversation, and the MCP `read_audit_trail` and `read_agent_logs` (conversation-scoped) tools passed the missing descriptor. Closed by the guard change: a non-admin gets "not found", while an admin can still read them for compliance. The ledger is append-only by design (EU AI Act), so the entries are guarded, not deleted. The REST `/auditstore` is already `eddi-admin` only.

**C1b: `/active` and `/end` had no role.** `GET /conversationstore/conversations/active/{agentId}` and `POST …/end` let any authenticated principal (even one without an `eddi-*` role) list every user's open conversation ids and end any of them. `/end` trusted the client's `conversationState`. A paused conversation sent as `READY` skipped the HITL cleanup. Any conversation sent as `AWAITING_HUMAN` wrote a forged `hitl.approval` cancellation to the audit trail.

* Both now take `@RolesAllowed({"eddi-admin", "eddi-editor"})` plus EDIT access on the agent. That is the same gate as undeploy, which already ends every active conversation of an agent. **The EDIT check is enforced only with workspaces on (`eddi.workspaces.enabled=true`, off by default).** Without it, the role is the whole gate and any editor can list and end any agent's open conversations. That is the reach undeploy-with-end already gives an editor, and a listed id grants nothing else, because every per-conversation endpoint is owner-or-admin.
* `/end` reads only the conversation ids from the request. It reads each conversation's state through the state projection and its agent from the conversation descriptor (live or archived), without loading the memory snapshot. Only a conversation with no descriptor at all falls back to the snapshot.
* **Authorization is all-or-nothing:** EDIT is checked for every conversation's agent before anything is ended. **Ending is per conversation and continues on error:** unknown and already ENDED ids are skipped. Every other conversation goes through `endConversation`, which decides server-side whether a pause is being terminated and attributes it to the calling principal (`system:admin-end` only if there is no named caller). The response body lists `ended`, `skipped` and `failed`, with status 200, or 500 if anything failed. Marking the descriptor ENDED is best-effort, since the listing re-derives the state from the snapshot. Undeploy-with-end now refuses to undeploy when the end reports a failure. The old raw `setConversationState(ENDED)` for non-paused conversations also skipped the in-flight signal and the state cache. A null body is a 400.

**NEW (soft-deleted active conversation broke undeploy).** `getActiveConversations` read the live descriptor of every open conversation and threw NotFound for one that had been soft-deleted while open. That failed the listing, and undeploy-with-end with it (500), and any user could trigger it with their own conversation. It now falls back to the archived descriptor (`lastInteraction` null if neither exists). `endActiveConversations` likewise ends a soft-deleted conversation.

**C1c: ownerless snapshot after a failed start.** `startConversation` stored the memory, then wrote the descriptor (which records the owner). If that write failed, the snapshot stayed with no owner on record. The start now discards the snapshot, the state-cache entry and any armed HITL timeout before rethrowing. The guard change also denies non-admins any such snapshot.

**M-E5 plus NEW: 500 instead of 404.** The conversation-id overloads of `undo`, `redo`, `isUndoAvailable` and `isRedoAvailable`, `PATCH /agents/{id}/state` (`resetState`) and `GET /llm/tools/history/{id}` dereferenced the null snapshot the store returns for an unknown id. They now answer 404.

### Decisions

* **`/active` and `/end` are editor + agent EDIT, not admin-only** as the review suggested. Undeploy (`eddi-admin`/`eddi-editor` + EDIT) already ends all of them, and calls these methods in-process. The Manager's conversation-monitoring page calls both, and editors should keep it. The real hole was "no role at all" plus trusting client state.
* **Admins keep access to descriptor-less conversations.** The guard gives them `null`, so an operator can still inspect orphans and the audit trail of a deleted conversation. `requireExistingConversationOwner` (attachments) stays a 404 for admins too, as before.
* **Owners keep read access to their soft-deleted conversations.** The snapshot is kept on purpose, and it is now ENDED, so it can be read but not continued.

**Files:** [`ConversationAccessGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/ConversationAccessGuard.java), [`RestConversationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/rest/RestConversationStore.java), [`IRestConversationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/rest/IRestConversationStore.java), [`ConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/ConversationService.java), [`RestAgentEngine.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java), [`RestToolHistory.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/rest/RestToolHistory.java), [`McpConversationTools.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpConversationTools.java), [`RestAgentManagement.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentManagement.java), [`RestAgentAdministration.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentAdministration.java), [`gdpr-compliance.md`](/security-and-compliance/gdpr-compliance.md), [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md). Regression tests are in `ConversationAccessGuardTest`, `RestConversationStoreTest`, `RestAgentEngineTest`, `McpConversationToolsOwnershipTest`, `McpConversationToolsTest`, `ConversationServiceTest`, `RestToolHistoryTest`, `RestAgentManagementExtendedTest` and `RestAgentAdministrationTest`.

***

## 🐛 fix(gdpr): erasure reaches connection grants and in-flight work; `_gdpr_` keys are reserved (2026-09-26)

**Repo:** EDDI (`fix/gdpr-erasure`)

Three gaps in the GDPR framework, from the 2026-09-25 code review (H9a, H9b, H9c).

### H9a: connection grants were outside erasure and export

OAuth connection grants hold a live refresh token for the user's account at a third party. `deleteUserData` never touched them, so an erased identity kept a working credential, and `exportUserData` did not list them.

* `IConnectionGrantStore` gains `findAllByPrincipal` and `deleteAllByPrincipal`, which span every tenant, like the rest of the cascade. They are implemented in Mongo (plus a new `idx_grant_principal` index) and Postgres (plus a new `idx_cg_principal`).
* The cascade adds a `connectionGrants` step, and `GdprDeletionResult` adds `connectionGrantsDeleted`, which also appears in the MCP `delete_user_data` payload.
* The export adds `connectionGrants`, the linked accounts as metadata only (tenant, connection, status, scopes, dates). Fields are copied one by one, so no token ciphertext or IV can reach the bundle.
* Not done: EDDI does not revoke consent at the provider. The docs say the user can revoke it there.

### H9b: in-flight work rewrote erased data

A turn running during an erasure wrote its longTerm properties back at teardown. `UserMemoryTool` writes mid-turn. A running group discussion upserted its document on the next phase, so the transcript came back.

* New SPI `UserErasureParticipant`, found through CDI like `SealedDataRotationParticipant`. The cascade calls it first, as step 0. `ConversationService` cancels this node's in-flight turns for the user, using the cooperative cancel flag. That flag already skips the longTerm write-back and discards the snapshot. `GroupConversationService` sends `CANCEL_IMMEDIATE` to the user's running discussions. If a participant throws, the step is recorded as failed (`inFlightConversations` / `runningGroupDiscussions`).
* `UserMemoryTool` refuses writes once its turn is cancelled (`ContextualToolsProvider` passes `memory::isCancelled`).
* `GroupConversationStore.update` no longer creates the document. It replaces an existing row via `storeIfCurrentVersion(SINGLE_VERSION)` and throws `GroupConversationGoneException` when the row is gone. This covers discussions on other replicas, and the plain delete endpoint as well as erasure.
* The cascade re-sweeps user memories at the end (step `userMemoriesResweep`). A step-0 signal cannot stop a write that is already in progress, or one made by a turn on another replica.
* Residual: a turn on another replica that finishes after the re-sweep can still upsert its longTerm properties. Conversation snapshots already refuse to recreate a deleted document. Closing this fully needs a cross-node erasure tombstone.

### H9c: the Art. 18 flag could be forged, overwritten or deleted

`isProcessingRestricted` read the first row under `_gdpr_processing_restricted`, whatever its category. So a model calling `rememberFact` could lock its own user out with a GDPR 403. A global `rememberFact` or a REST `mergeProperties` overwrote the admin's row in place, because global rows are keyed on `(userId, key)`. REST/MCP entry deletes, REST `deleteProperties`/delete-all, and whole-set Dream pruning could all lift a restriction without the audited endpoint. `unrestrictProcessing` removed only one row.

* `IUserMemoryStore.upsert` and `mergeProperties` refuse reserved keys in both backends, with `ReservedMemoryKeyException` (an `IllegalArgumentException`). The only write path for them is the new `upsertReserved`, used by `GdprComplianceService`.
* `isProcessingRestricted` counts only rows in the `gdpr` category, and considers every one of them. `unrestrictProcessing` deletes every row under the key.
* `UserMemoryTool` (`rememberFact`/`forgetFact`), `RestUserMemoryStore` (upsert/delete → 400), `RestPropertiesStore.mergeProperties` (400) and `McpMemoryTools` refuse reserved keys. The delete-all surfaces now use `deleteAllExceptReserved`, and the store's `deleteProperties` keeps reserved rows. The MCP `delete_all_user_memories` description now says so. Dream never maintains reserved keys. A reserved longTerm property is skipped at teardown with a WARN rather than failing the turn. The legacy properties migration skips reserved keys instead of counting them as failures.
* Postgres retention and `deleteProperties` now escape the LIKE underscores. The old `'_gdpr_%'` also matched keys such as `agdpr1`, and the retention sweep never pruned those.
* Save-time rejection of a property-setter config that names a `_gdpr_` key was not added. It is skipped at runtime.

### Pre-push review follow-up

* **Deleted mid-run.** A running discussion whose document is deleted mid-run now ends as a cancel. `executeDiscussion` handles `GroupConversationGoneException`, also when it arrives wrapped, from any write inside the leg. The R2 cancel branch tolerates a document that is already gone. The listener gets `onCancelled` rather than `onGroupError`, and the leg no longer logs an ERROR, bumps the failure metric or throws a 5xx. Previously the second `update()` inside the catch threw Gone again and left SSE streams hanging. `GroupLifecycleOps.failConversation` has a corrected comment.
* **Late audit entries.** A cancelled turn still flushes its audit buffer while it unwinds. `AuditLedgerService.markUserErased` makes this node write the user's pseudonym for one hour, both at submit and when queued entries are drained. The v3 HMAC still verifies, because it covers the identity token, not the raw id.
* **Pending OAuth flows.** `IOAuthStateStore.deleteByPrincipal` (Mongo, Postgres) runs as step `oauthStates`, before the grants are deleted. A late callback can no longer mint a grant for an erased principal.
* **Signalling continues past errors.** `GroupConversationService.stopInFlightWork` keeps signalling after a store read error and reports the failure once, at the end.
* **Stricter restriction check.** A row counts as an Art. 18 restriction only if it also has no `sourceAgentId`, which is the shape only `restrictProcessing` writes.
* **`__service__` is refused.** Erasure and export reject this system principal: 400 on REST, an error on MCP, and an `IllegalArgumentException` in the service.
* **LIKE escape character.** The Postgres LIKE escape is now `!`, which does not depend on `standard_conforming_strings`.
* **Access check before key check.** REST `upsertMemory` runs the ownership check before the reserved-key 400.
* **Docs.** Stale "GDPR delete-all" wording in `IRestUserMemoryStore` (OpenAPI), `docs/user-memory.md` and `docs/mcp-server.md` is fixed, and the step numbering in `docs/gdpr-compliance.md` is corrected.
* **Remaining residuals.** A turn or callback still in flight on another replica is not covered. Neither is a callback that claimed its state before step `oauthStates` and stores its grant after step `connectionGrants`, a window the length of one token exchange.

### CodeQL follow-up

* **Two `java/sensitive-log` alerts in `GroupHitlCoordinator.cancelDiscussion`.** The erasure sweep (`GroupConversationService.stopInFlightWork`) walks the keys of the map of running discussions, then named `activeTokens`. CodeQL's heuristic reads any variable whose name contains "token" as a credential, so each key it yielded counted as a secret, and the two log lines in `cancelDiscussion` that print the conversation id became alerts. The keys are group-conversation ids and the values are cooperative stop flags (`DiscussionControlToken`); nothing secret is logged. The map is renamed `discussionControls` in the five classes that share it (the reflective test handles follow), which states what it holds and removes the false source, instead of suppressing the alert. The two log lines also now pass the id through `LogSanitizer`, like the rest of the class. No behaviour changes.
* **`McpMemoryToolsBranchCoverageTest` still expected the old delete.** Two of its cases stubbed `countEntries` and verified `deleteAllForUser`, which `delete_all_user_memories` no longer calls since it switched to `deleteAllExceptReserved`. They now stub and verify that method, and assert that `deleteAllForUser` is never called from this surface.

### Compatibility

* REST/MCP shapes only gain fields: `connectionGrantsDeleted`, and `connectionGrants` in the export. The old constructors of both records remain.
* Writes of `_gdpr_*` keys through the memory and property REST/MCP endpoints now answer 400 or an error, where they used to succeed.
* Stored rows need no migration. Pre-existing forged rows outside the `gdpr` category are ignored by the check and removed by unrestrict.

### Overlap

H14b on `fix/group-conversation-state` also changes group-discussion delete and resurrection. This branch makes only the store-level non-upsert change and the erasure cancel, and leaves delete-endpoint cancel tokens and CAS writes to that branch.

***

## 🐛 fix(manager): align the Manager's API client and MSW mocks with the real backend contract (2026-09-26)

**Repo:** EDDI (`fix/manager-api-contract`) — Manager only (`ui/manager`), no backend change.

The Manager's suite was green while a dozen calls disagreed with the backend: the MSW mocks encoded the same wrong contract as the app. This branch fixes the app side of each mismatch and makes the mocks answer the way the backend does, so the tests would now fail on a regression.

### What changed and why

* **Undo / redo (UI review High 1).** `POST /agents/{id}/undo|redo` answer an empty 200 (moved) or 409 (nothing to move); the client typed them as returning a snapshot, so the hook threw on `undefined` *after* the server had undone the step and every further click undid another turn. `undoConversation` / `redoConversation` now resolve `true`/`false`, and the hook re-reads the whole conversation (`returnCurrentStepOnly=false`) for the transcript and both flags, ignoring the read if the user switched conversation meanwhile. Undo availability is now set after every send (streaming `done` and non-streaming) from the backend's `undoAvailable`, with a `steps > 1` fallback (was `> 0`, which offered Undo on a fresh conversation). Tests: `chat.test.ts`, `use-chat.test.tsx` (`useUndoConversation`, `useRedoConversation`, `undoRedoFlags`, "enables Undo once a turn lands").
* **Agents / Workflows lists stopped at 50 (High 2).** The infinite queries sent `allPages.length * 50` as `index`; the backend's `index` is a page index (`skip = index * limit`). Now `allPages.length`. The sync page and the import dialog's target pickers only ever loaded the first page, so they now use `useAllAgentDescriptors`, which keeps paging until a short page — past 50 local agents, sync no longer creates duplicates. Tests: `use-agents.test.tsx` ("asks for page 1…", `useAllAgentDescriptors`).
* **Version pickers offered only the latest version (High 3).** They asked the store listing `descriptors?filter=id&version=v`, which has no `version` parameter. New `getDescriptorVersions(id, latest)` reads `/descriptorstore/descriptors/{id}?version=v` per version (bounded concurrency, unreadable versions skipped); used by agent, workflow and resource version lists. Tests: `descriptors.test.ts`, `use-agents.test.tsx`, `use-workflows.test.tsx`.
* **`manager-user` (High 11).** Group start / follow-up / continue fell back to `userId: "manager-user"`, which `OwnershipValidator` rejects with 403 for every non-admin (and stamps admins' runs with a synthetic owner). The field is now omitted unless a caller names a user, so the backend resolves the signed-in caller. Tests: `groups-wave-parity.test.ts` ("discussion owner").
* **Properties page.** `GET /propertiesstore/properties/{userId}` returns raw values, not `Property` wrappers, and 204 for a user with none. Types, rendering and the empty case fixed; the "Delete all" dialog now says it removes every global user-memory entry of the user (11 locales). Tests: `properties.test.ts`, `pages/__tests__/properties.test.tsx`.
* **Audit `toolCalls`.** The backend field is a map `{calls: [...]}` of trace events; new `auditToolCalls()` normalises it (pairs results with calls, skips budget/HITL markers) for the pipeline trace and the prompt viewer. Tests: `audit.test.ts`, `prompt-viewer.test.tsx`.
* **Memory Inspector** now sends `returnCurrentStepOnly=false` (backend default is `true`, so it only ever showed the last step). Test: `conversations.test.ts`.
* **Validation errors.** `extractErrorMessage` now reads Quarkus's Bean Validation report (`violations[].message`, plus the RESTEasy Classic shapes), so `@Valid` 400s on group saves / discuss / attachment limits show the constraint message instead of "Bad Request". Test: `api-client-response.test.ts`.
* **`getToolHistory`** returns a `ToolExecutionTrace` object (`toolCalls`, totals), not an array; type and mock fixed. Tests: `tool-metrics.test.ts`, `use-tool-metrics.test.tsx`, `use-admin-hooks.test.tsx`.
* **Deployment status at any version.** Every save bumps the version, and the per-version status endpoint then says NOT\_FOUND although an older version is live. `useDeploymentStatuses` and the chat picker now consult `GET /administration/{env}/deploymentstatus` (one shared listing per environment) and, for an environment with nothing at the asked-for version (NOT\_FOUND), adopt an older READY version, flagged `deployedVersion`; the card's chip shows `vN` and its deploy toggle still acts on the card's own version. Tests: `deployment-environments.test.ts`, `use-agents.test.tsx`, `agent-card.test.tsx`.
* **Dashboard counts.** The conversation count asked for 1000 but the backend clamps to 100, and "100" was shown as a total. It now asks for 100 and renders a page-filling count as "100+" (agents/workflows likewise at 1000). Tests: `dashboard.test.ts`, `pages/__tests__/dashboard.test.tsx`.
* **MSW handlers.** Mocks now use page-index pagination, clamp conversation listings at 100, return empty 200s for undo/redo, raw property values (204 for an empty user), the `{calls}` audit shape, the trace-object tool history, an environment deployment listing, and 201 for `POST /backup/import`. 48 shadowed duplicate handlers were deleted (a second schedule store, coordinator, audit, quota, currentversion, descriptor and tool-metrics copies, the `agents/:id` memory-inspector copy, the factory `/descriptors` copy). Four of the removed copies were the ones that actually *answered*, so their routes now answer from the `backupSyncHandlers` copy instead: `POST /backup/export/:agentId` (Location `agent-export.zip`, was `test-agent-1.zip`), `GET /backup/export/:filename`, `POST /backup/import/preview` (the current `ImportPreview` shape, was a pre-6.x one) and `POST /backup/import` (201 with `imported-<timestamp>`, was 200 with a fixed `imported-agent`; a merge still lands on `agent1` v2). `openapi-contract.test.ts`'s `KNOWN_DUPLICATE_ROUTES` ratchet shrinks from 58 entries to the 10 deliberate `:id` fall-through layers.

### Review follow-up

* **Agent detail Environments panel** now uses `isLiveAtRequestedVersion`: an environment live at an older version shows `vN` and offers **Deploy** for the page's version, never **Undeploy**. Undeploying a version that is not running is accepted by the backend, which then disables all of the agent's schedules while the old version keeps serving. Test: `agent-detail.test.tsx` ("shows an older live version per environment…").
* **Sync auto-match waits for the whole local list** (`isComplete`); remote agents received earlier are matched when it completes, and a failed page shows an error with Retry instead of matching a partial list (2 new keys, 11 locales). Tests: `sync-page.test.tsx`.
* **`getDescriptorVersions` skips only 404s**; any other error fails the list. Test: `descriptors.test.ts`.
* **Undo/redo**: one move at a time (a call while one is in flight is dropped), the chat panel disables both buttons while either is pending and toasts failures, and a failed re-read after a move disables both buttons (the transcript can no longer be trusted). Undo, redo and rerun all ignore their result when the user switched conversation meanwhile; binding them to the chat store's conversation epoch (fix/manager-chat) is a follow-up. Tests: `use-chat.test.tsx`.
* **Deployment fallback adopts only READY** listing rows — an adopted, unpolled IN\_PROGRESS kept the card's toggle disabled. Documented gap: the listing holds the highest deployed version whatever its status, so when that one is ERROR an older READY version is not visible (no backend "latest READY" listing). Test: `deployment-environments.test.ts`.
* **Deployment fallback replaces only NOT\_FOUND, never at the asked-for version** (second review). A READY listing row *at* the requested version contradicts the exact NOT\_FOUND it would replace, so it is stale: undeploying v4 set the exact status to NOT\_FOUND optimistically while the shared, cached listing still said v4 READY, and the card showed v4 live again until the refetch. `withAnyDeployedVersion` now takes the requested version and skips such rows. And an ERROR at the requested version is kept instead of being replaced by an older READY one — it had turned a failed v4 deploy into a green production chip and hidden `env-chip-error-*`. The listing is now fetched only when some environment is NOT\_FOUND. Tests: `deployment-environments.test.ts`, `use-agents.test.tsx` (undeploy with a cached listing; ERROR kept), `use-chat.test.tsx` (`useDeployedAgents`), `agent-card.test.tsx`.
* **Chat panel serialises undo, redo and rerun with sends** (second review). Each of the three finishes by replacing the transcript with a fresh read, so while one is in flight the composer is disabled, quick replies are hidden, the Retry button is hidden during a move, and Undo/Redo are unavailable during a rerun. Before, a send or quick reply issued during an undo raced its re-read. Tests: `chat-panel.test.tsx` ("holds every other way…", "offers no undo, redo or send while a rerun is in flight").
* **`auditToolCalls`** attaches `tool_error` reasons (budget, quota, HITL cap) to the refused call, or lists an orphan refusal. Test: `audit.test.ts`.
* **`useAllAgentDescriptors` has its own cache key**, so the Agents list does not inherit (and re-fetch on every invalidation) every page Sync loaded. Test: `use-agents.test.tsx`.

### Compatibility

No REST, config or MCP shape changes. Compatible with current `main` and with the open backend PRs: #840 (workspace scoping) may narrow the deployment listing or descriptor reads to what the caller can see — the fallback then simply does not apply; #839 (`pauseId`), #842 (schedules) and #836 (vault adoption) touch endpoints this branch does not change; #831 does not affect the discuss body. Group discussions started from the Manager with auth **off** are now owned by `anonymous` (the backend's default) instead of `manager-user`.

### Not in this branch

The chat picker still de-duplicates agents by name (fix/manager-chat); the `groups.ts` NUL byte is chore/ci-hardening's. Group/workforce create-flow error messages benefit from the violation parsing, but their generic-error wrappers belong to fix/manager-workforce-groups.

***

## 🧪 fix(manager): a passing test file no longer fails UI Manager Checks on a late toast timer (2026-09-26)

**Repo:** EDDI (`fix/manager-toast-timer-race`) — `ui/manager/` test only

### The failure

`UI Manager Checks` sometimes exited 1 while every test passed (426 files, 6858 tests on PR 847's run 36271130343), on one unhandled error:

```
ReferenceError: window is not defined
 ❯ resolveUpdatePriority  react-dom-client.development.js
 ❯ dispatchSetState
 ❯ sonner/dist/index.mjs  (the Toast's unmount timer)
This error originated in "src/pages/__tests__/resource-detail-save-not-live.test.tsx"
```

### Cause

`resource-detail-save-not-live.test.tsx` renders `<Toaster duration={600_000} />` and calls `toast.dismiss()` in `afterEach`, because sonner's toast store is module-global and a toast left over from one test breaks the next. A dismissed toast is removed only after sonner's `TIME_BEFORE_UNMOUNT` timer (200 ms), and that callback updates React state. Between tests the next test keeps the environment alive long enough; after the file's **last** test nothing waits for it, so whether it fires before or after jsdom is torn down depends on runner timing. Fired afterwards, React's update-priority lookup reads `window` and throws.

Instrumenting sonner locally showed it plainly: before the fix the last test's timer was still pending when the file finished (3 started, 2 fired); after it every timer fired with `window` present (11 of 11, at the real 200 ms and at a widened 800 ms).

### Fix

`afterEach` still dismisses, then waits until no `[data-sonner-toast]` element remains — which only happens once the unmount timer has run — so the state update lands while jsdom is up. Nothing else changed. The three other test files that render `<Toaster>` never call `toast.dismiss()`; their toasts unmount with the component, which starts no removal timer, so they do not have this race.

### Verification

* The file run 20 times in a row: 20 passed, no "Unhandled Errors" section.
* `npx eslint` on the file and `npx tsc -b`: clean.

***

## 🐛 fix(manager): pages move onto the version a save created (2026-09-26)

**Repo:** EDDI (`fix/manager-version-after-save`) — UI review High 4, plus the Save & Deploy `ERROR` handling

### The problem

Every config PUT creates a new version and names it in `Location`; the backend refuses a write to a version that is no longer current with a 409. Several Manager flows ignored that header and kept the version they were opened with, so the edit appeared to revert on the next refetch and the next action failed.

### What changed, per flow

* **Cascade save** ([`cascade-save.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/cascade-save.ts)). Two failure modes, both fixed:
  * *Exact match.* The agent's workflow reference was replaced only on an exact string match, and nothing checked that anything had been replaced, so an agent that spelled the reference differently was re-saved without the edit, and Save & Deploy shipped it. Both parents are now read and checked **before the resource is written**. The workflow step must end in `/{store}/{plural}/{id}` (the old substring test also matched ids that merely start with this one). The agent reference is matched by parsed id and version in any spelling. A missing reference throws `CascadeReferenceError` with nothing written, so no orphaned resource version is left behind.
  * *Recovery.* A hop that fails after the resource was written throws `CascadeSaveError`, carrying the versions that now exist and a `retryContext`. When only the agent hop failed, the new optional `CascadeContext.agentWorkflowVersion` records that the agent still points at the previous workflow version, so the retry repoints it instead of refusing. The resource editor, the Studio panel and the prompt editor (`useUpdateAgentPrompt`) adopt these versions, so a retry completes instead of 409ing until a reload discards the edit. Path B ("update usages") now passes `agentWorkflowVersion` for a second agent sharing a workflow that has already moved on. Before, the exact-match bug skipped that agent silently.
* **Agent Studio** ([`agent-studio.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/agent-studio.tsx), [`studio-editor-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/studio/studio-editor-panel.tsx)). The page, not the per-stage panel, now owns the workflow and agent versions, and the panel reports the next cascade context through `onCascadeContextChange`. The workflow query is keyed by version, so a reopened stage edits the version the save created. Placeholder data keeps the pipeline on screen while it loads.
* **Group detail** ([`group-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/group-detail.tsx)). `useUpdateGroup` now resolves with the version the save created and seeds the cache under it. The inline HITL, phase and advanced editors report that version through `GroupConfigPanel.onVersionChange`, and the page writes it to the URL. `deleteGroupWithMembers` deletes the group **first**, so a refused group delete no longer leaves every member agent soft-deleted.
* **Workforce settings** ([`workforce-settings.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workforce/workforce-settings.tsx)). After a save the URL moves to the new version. The form adopts the server's (normalised) copy while it still holds exactly what was saved, and never while an edit is in progress. Before, it stayed dirty forever and a second save or a delete 409'd.
* **Agent config sections** ([`use-agent-section-save.ts`](https://github.com/labsai/EDDI/blob/1fa4749423c66b3264abb2a7785e1aebc907da92/ui/manager/src/hooks/use-agent-section-save.ts)). The detail page's inline sections, and the A2A section, save through one hook. Its state is **per agent at module scope**, shared by every section, not per section: saves to one agent run one at a time, in order, whichever section made them. Each save is chained to the version the previous one created for as long as the page lags behind it. The fields this render changed are merged onto the document that version holds: the last save's, or the cached copy of the page's version when the section is still showing placeholder data from another version (so a stale placeholder can no longer overwrite a newer version). Failures are toasted, and `isPending` counts queued saves as well as the one in flight. A 409 (another client wrote a newer version) also drops the chain and invalidates the agent queries: a failed save refetches nothing by itself, so every later save would repeat the 409 until something else refreshed the page. Any other failure keeps the chain, because the version the last save created is still current and the page may not have caught up to it yet. Before, a quick second edit 409'd and was silently dropped. `useUpdateAgent` seeds the new version in the cache. Picking the latest version in the agent-detail picker now means "follow the latest" rather than pinning its number.
* **Save & Deploy** ([`use-save-and-deploy.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-save-and-deploy.ts)). A deployment `ERROR` stops polling and reports "Deployment failed" at once. The throw had sat inside the try whose catch swallowed it, so a failed deploy was polled for 30 s and reported as "Deploy timed out". Only a failed status *read* is retried now.

### Review round

* **Stale parents are refused before anything is written.** `loadParents` also reads `/currentversion` for the workflow and the agent. If either is newer than the version the page addresses (changed in another tab, say), the cascade throws `CascadeReferenceError` (`workflowChanged` / `agentChanged`). Before, the resource and workflow were written and only the agent PUT 409'd, and a retry with the handed-out context did the same again, leaving an orphan per attempt. An unreadable or lower answer is not treated as evidence; the PUTs still guard.
* **The cascade's refusals are translated.** `CascadeReferenceError` carries a `code` and `params`. `describeSaveError` (`src/lib/save-error.ts`) renders them from the new `cascadeSave.*` keys in all 11 locales, for the resource editor, Save & Deploy and the Studio.
* **Studio.** The panel is keyed by stage and resource id instead of the full URI, so a save no longer remounts it (which discarded anything typed meanwhile and reset the saved indicator). `useCascadeSave` seeds the version it created, so the editor does not drop to a loading state either. The page also remembers the newest version each resource was saved at, so returning to a stage after a workflow-hop failure does not address the superseded resource version.
* **The prompt editor's recovery survives unmount.** It is held at module scope, keyed by agent and LLM id, instead of in the hook instance.
* **Version lists refresh after a partial failure.** `useCascadeSave` also invalidates on a `CascadeSaveError`, so the picker offers the version the page moved onto.
* **Workforce settings: the post-save guard waits for the URL once** (an `awaitingUrl` flag), instead of refusing every version lower than the last save. A move back to an older version is followed again.

The strict `Location` parser moved from `cascade-save.ts` to [`location-version.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/location-version.ts), unchanged, so the group and agent hooks can share it.

### Backend compatibility

Every version the UI now uses comes from a `Location` header, which is always the live version. So the flows behave the same against current `main` and against the pending backend change that makes descriptor PATCH answer 409 on a non-current version. No REST shape, stored config or ZIP format changed.

### Not done here

* A group URL without `?version=` still defaults to 1. That is correct for a freshly created group, but a hand-typed link to a later version will 409 on save. Resolving through `/currentversion` is a follow-up.
* `useAgentVersions` (the agent-detail version list) belongs to `fix/manager-api-contract`. The "follow latest" change relies only on its first entry being the newest.

***

## 🐛 fix(manager): Workforce and group discussions — Stop cancels, streams hand over, configs save what was entered (2026-09-26)

**Repo:** EDDI (`fix/manager-workforce-groups`)

Fix-plan item 22: UI review High 10, the Workforce/group Medium items, G2, G4, P2, IME and the group search debounce. Manager only, apart from three backend strings (P2).

### Stop and "+ New" stop the discussion (UI High 10)

* The board's **Stop** only closed this tab's SSE connection: the discussion kept running and spending on the server, and the board froze on the last frame while still saying new answers would appear. Stop now calls `POST /groups/{id}/conversations/{gcId}/cancel` (confirmed first, like the Manager's cancel) and closes the stream only once the cancel succeeded. A 409 means the run already ended on its own — the stream is closed, but the state is left to the stored document rather than claimed as CANCELLED.
* Stop pressed before `group_start` has named the conversation is remembered (`cancelRequested`) and sent as soon as the id arrives, instead of closing a connection to a run nothing could then reach.
* **"+ New"** during a live discussion asks first and stops it, instead of leaving it running and letting a second run start beside it.
* New store action `cancelStream`; `abortStream` keeps its meaning (detach, used when switching discussions) and is no longer what the board's Stop does.
* **Stop without a stream (review follow-up):** a discussion running with no stream in this tab — the connection dropped (`interrupted`), or the board adopted it from the stored list after a reload — shows Stop too, and "+ New" asks before leaving it. `cancelStream(groupId, gcId)` cancels such a run by its known id, without creating a store entry for a discussion this tab never streamed. A successful cancel clears `interrupted`, so the "connection lost, keeps updating" notice does not outlive it.
* "Stop and start new" confirmed before `group_start` named the conversation now starts the new discussion once the pending cancel lands; a cancel that fails drops the intent and leaves the run on screen.
* A deferred cancel the server refuses is reported like a refused immediate one — the "Could not stop the discussion" toast, through a separate `cancelError` field the board clears once shown — and no longer through the stream's `error`, which the board renders as the discussion itself failing while it was in fact still running.
* **Stop targets the discussion on screen (review follow-up):** with one discussion streaming in this tab and another running one opened from Sessions, Stop cancelled the stream — the run the user was not looking at — and left the selected one running. The board now passes the selected discussion's id, fixed when the confirmation opens, and `cancelStream` cancels a named discussion that is not the streamed one by id, leaving the live stream alone. A finished discussion browsed during a stream shows no Stop ("Back to live discussion" leads to the running one). "+ New" still stops the tab's stream when there is one, since clearing the board detaches from it.
* **Stop stays on its board (review follow-up):** switching task forces keeps the board mounted and rebinds the stream hook, so a Stop confirmation left open would have sent the old board's discussion id under the new board's group. The confirmation is dismissed when the board changes, and a pending or in-flight "Stop and start new" now clears only the board it was asked on, not the one the user moved to.
* **A 409 is checked, not believed (review follow-up):** the cancel endpoint answers 409 both for a discussion that had ended and for a cancel that lost a state race on a paused one (`GroupHitlCoordinator.cancelDiscussion`). The board now reads the stored state before saying "already ended"; if the discussion is still going it reports a failed Stop (new key `Workforce.board.stopRaced`, all 11 locales), keeps Stop on offer, and "Stop and start new" does not clear the board. Only a stored terminal state counts as ended: a read that fails leaves the Stop unconfirmed (new key `Workforce.board.stopUnconfirmed`, all 11 locales) and is handled like a discussion still going, never taken as the 409's word that it ended.

### Streams that end, and members that "type forever"

* `speaker_complete.outcome` (`TIMEOUT` / `SKIPPED` / `ERROR`, from the group-conversation-state backend branch) closes the member's placeholder as SKIPPED or ERROR with no content — a failure is never shown as something the member said. A completion with no content from an older backend closes as SKIPPED too. An unknown outcome value reads as a contribution.
* Against a backend without that field, a PARALLEL member released by the batch deadline got no completion at all and its placeholder typed for the rest of the discussion. `phase_complete` now closes the phase's open placeholders, and every terminal event (`group_complete`, `group_error`, `cancelled`, `awaiting_approval`, `human_input_requested`) closes all of them.
* A connection that ends **without a terminal event** (proxy timeout, pod restart) is now `interrupted`, not a silent "not streaming": the board shows a notice and follows the stored conversation (which polls while it runs); the Manager's group page selects it. A network error **after** the first frame is treated the same way — it used to mark a still-running discussion FAILED. A failure before any frame (the request was refused) is still FAILED.
* The board used to keep the frozen live transcript after a stream settled, so follow-ups and later phases never appeared. It now switches to the stored document as soon as that has caught up with what the stream delivered (`deliveredRowCount`), so the switch never flashes an older transcript. The Manager's group page applies the same caught-up rule (`persistedHasCaughtUp`) when it hands an interrupted stream over, instead of showing a stored copy that is still behind until the next poll.
* A PARALLEL member whose turn threw shows ERROR live only with #843's `outcome` field. Against current `main` the backend sends no completion and stores the failure as an unattributed row, so the live placeholder closes as SKIPPED on `phase_complete` and the stored copy corrects it on the switch — there is nothing live that says which member failed.
* A refused stream start now carries the backend's own sentence (`streamRefusalMessage`) instead of "400 Bad Request".

### Board and thread

* **Attachment-only sends** are blocked in the composer with a hint — the backend requires a question and its 400 left the board on a full-page error with no actions. That error screen now has "Start over" (and "View past sessions"): it lives in the per-board stream store, which outlives navigation, so it used to be a dead end.
* **REJECTED** gets its own composer message on the board ("This recommendation was rejected"), as on the Manager page (P2).
* **Version links:** the board, the advisor thread and the history page are reached by links without `?version=` and read version 1 — the group's FIRST version, with its original name, roster and phases. They now resolve the current version (`GET /groupstore/groups/{id}/currentversion`, `useResolvedGroupVersion`) and never fetch version 1 while it is in flight. The history page learned that version from the enriched descriptor listing — up to 201 requests for one number; it is now one.
* **Thread errors:** a thread that could not be opened showed an empty, healthy-looking thread with a dead composer; it now says why, with Retry and New conversation. A stored conversation that no longer exists (404/410) is replaced with a fresh one. Moving from one advisor's thread to another (same page, new route param) kept the first advisor's messages and conversation: initialisation is now keyed per (board, member) and re-checked after every await. "New conversation" is bound the same way: pressed on one advisor and still in flight when the reader moves on, it registers the new conversation for that advisor without writing it into the next one's view.
* **IME:** the Enter that confirms a Chinese/Japanese/Korean composition no longer sends the question, in the board composer, the Manager's discussion input (both textareas) and the thread input (`lib/ime.ts`).

### Group page (P2)

* A rejection is confirmed: the backend ends a rejected run with `group_complete` state REJECTED and never sends `hitl_resume`, which was the only thing the page waited for.
* "New Discussion" pressed on one group no longer suppresses auto-selection on the next group the (still-mounted) page navigates to.

### Overview (G2, G4)

* The round's QUESTION row (stored at phaseIndex 0, phaseName "Question") no longer names phase 0 "Question" or marks it started.
* A picked round is reset when the panel moves to another discussion.
* An earlier round no longer borrows whole-discussion records: its stances are extracted from its own turns, its cost is unknown (null) rather than the total, and it shows its own synthesis and no later verdict. That includes the decision card the surfaces hand the overview as `outcome` (built from the stored or streamed decision, i.e. the newest round's): the overview shows it only while the newest round is selected.
* The Workforce history viewer passes the group's `style` to the overview.

### Configs that did not save what was entered

* **NEGOTIATION preset:** materializing the phases (which enabling any approval point does) stored Arbitration with `inputTemplate: null`, so the moderator ran the generic synthesis prompt instead of the arbitration brief. `NEGOTIATION_ARBITRATION_TEMPLATE` mirrors `TEMPLATE_ARBITRATION`, and a test compares it with the Java text block (path resolved from the test file; escapes other than line continuations are refused rather than guessed at; the closing delimiter's indentation counts, per JLS 3.10.6). Groups already stored that way are **repaired on read** (`repairNegotiationArbitration` in `normalizeGroupConfig`): only the exact preset phase — NEGOTIATION style, "Arbitration", MODERATOR SYNTHESIS, `skipIf AGREEMENT_REACHED`, no prompt of its own — gets the prompt back, and the stored document is healed by the next save from any editor. Until then the backend still runs those groups with the generic synthesis prompt; a backend read-time default is a follow-up.
* **Assignment mode:** `normalizeGroupTaskConfig` rebuilt the block from the three fields it normalizes, so Workforce settings saved BID back as ROLE. It now carries every other field through.
* **Retro:** the backend has no "off" for retro — a null `retroConfig` means the default caps. The checkbox is now "Custom retro lesson limits" and says that unticking keeps the defaults (remove the RETRO phase to stop it). Saving spreads the existing block, so `maxLessonChars` survives.
* **Vote options:** the explicit-options textarea was bound to the cleaned list, deleting the newline and trailing space as they were typed — a second option or a two-word option could not be entered. It keeps its own draft now, and an EXPLICIT ballot with fewer than two options blocks the save with a message (the backend's 400).
* The phase, advanced and HITL editors show the backend's error sentence instead of "Something went wrong".

### Create flows (validation messages)

* `groupSaveProblems` mirrors `AgentGroupStore`'s hard rejections — DEBATE / DEVIL\_ADVOCATE preset roles (only for groups without explicit phases), unassigned members, HUMAN members' name and principal id, HUMAN members in task-force or peer-targeted phases (preset-expanded). The create dialog, the group wizard and the Workforce wizard show them on the last step and hold Create back. The two wizards create and deploy member agents before saving the group, so this runs before the first agent is created. Their `onError` shows the backend's sentence.

### Search and paging

* The Groups page search is debounced (300 ms) and keeps the previous results while a new filter loads: every keystroke was a fresh enriched listing, one descriptor request plus one config read per group.
* Owner-filter pagination (frontend half): **no production change.** The history still fetches `index 0` with a limit that grows by 20 per "Load more", and shows "Load more" while the response fills that limit — correct once the backend filters by owner in the query (the group-conversation-state branch), so it was left as is. A test now pins that behaviour, and the MSW handler honours `index`/`limit` as the row offset the backend uses. Against the current backend a non-admin can still get a short page, which the client cannot detect.

### Backend strings (P2)

* `RestGroupConversation` close 409 text, `IRestGroupConversation` close 409 description and the MCP `start_group_discussion` description now name REJECTED; the MCP text also names the two waiting states, so a polling client does not wait for COMPLETED or FAILED forever.

### Not changed

* `ui/manager/src/lib/api/groups.ts:1622` (the literal NUL byte) is untouched; this branch edits other parts of the file byte-safely. Item 17 owns it.
* The group page's own `?version=` default of 1 is left to the version-after-save branch (item 19).
* `api-client.ts`'s `extractErrorMessage` still does not parse Quarkus `violations[]` bodies (not in this item).

**Files:** [`use-group-discussion-stream.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-group-discussion-stream.ts), [`workforce-board.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workforce/workforce-board.tsx), [`workforce-thread.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workforce/workforce-thread.tsx), [`use-discussion-digest.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-discussion-digest.ts), [`group-config.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/group-config.ts), [`hitl-config.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/hitl-config.ts), [`group-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/group-detail.tsx)

***

## 🔒 fix(security): close a group of authorization/IDOR gaps across REST, tools and health (2026-09-26)

**Repo:** EDDI (`fix/security-access-control`)

### What changed and why

A batch of verified access-control gaps were hardened. Each is scoped so that legitimate flows (the Manager, admins, internal orchestrators, auth-disabled dev) keep working, while the missing owner/role/redaction checks are added.

* **Active-conversation endpoints role-gated.** `GET /conversationstore/conversations/active/{agentId}` and `POST /conversationstore/conversations/end` were reachable by any authenticated token. The same gap was closed independently on `main` by the soft-delete owner-check fix (`fix-conversation-access-guard`), which also adds an agent EDIT check under workspace enforcement and server-side state for `/end`; that is the contract kept: `eddi-admin`/`eddi-editor` (the tier the Manager's conversation-monitoring page runs as), plus EDIT on the agent when workspaces are on. This batch adds `SecurityAccessControlAnnotationsTest`, which pins the role set by reflection.
* **`converse_with_agent` cross-user access.** The tool now requires a model-supplied `conversationId` to belong to the same user the tool is bound to, so the LLM can no longer continue another user's conversation by supplying its id. New conversations (started as the bound user) and internal group-orchestrator callers are unaffected. A conversation that records no owner is refused too (fail closed): its ownership cannot be established, and the model supplied the id. So is any supplied id when the tool has no bound user (the parent conversation records none): there is nothing to compare the owner against. Starting a new conversation is unaffected.
* **Schedule `persistentConversationId`.** Create now nulls a caller-supplied `persistentConversationId` (mirroring import); the fire path additionally refuses to reuse a persistent conversation owned by a different user than the schedule.
* **Schedule ownership.** Reads (`readSchedule`, `readAllSchedules`) and the remaining state-changers (`delete`, `enable`, `disable`, `retry`, `dismiss`) now apply the same owner check `create`/`update`/`fire` already used, so an editor cannot see or mutate another user's schedule; unowned/system schedules stay shared. The listing's owner filter is pushed into the store query (`ScheduleOwnerScope`, Mongo and Postgres), like the HITL-timeout redaction, so `limit`/`offset` count only the caller's visible rows: a non-admin gets a full page of their own schedules rather than a short or empty one that the paging contract would read as the end of the list.
* **Agent-trigger ownership.** Triggers carry no owner field, so `delete`/`update` are now gated on USE access to the agents the stored trigger routes to — a foreign editor can no longer re-point or remove another team's trigger.
* **`UserMemoryTool` visibility guardrail.** The tool now applies the configured `defaultVisibility`, restricts writable visibilities to a configurable allow-set (default `self`), and refuses overwriting a `global` key owned by another agent unless configured — including a global key whose entry records no owning agent (legacy or migrated data), since the guard cannot show it is this agent's. Two new `guardrails` fields, documented in `docs/user-memory.md`: `allowedVisibilities`, `allowGlobalKeyOverwrite`.
* **`returnDetailed` step-data exposure.** The detailed conversion now drops sensitive internal keys (`audit:*`, `*:trace:*`, `*Error`) and runs values through `SecretRedactionFilter`, matching the SSE path; full-fidelity debugging remains on the owner/admin-gated raw endpoint. Redaction recurses into `Map` keys and values, collection elements and `Object[]` elements, so a secret embedded in a structured value (e.g. a deserialized httpCall response body under an agent-chosen key, or a token-keyed map) is masked too, not just top-level strings. A value under a credential-named key (`apiKey`, `token`, `secret`, `password`, `authorization`, at any depth) is masked outright even without a credential shape, since walking a map separates the name from the value the name-bound filter rules need; the filter's usual exemptions (under 8 characters, a vault reference) still apply. A map, list or array under a credential-named key is replaced as a whole, so a credential used as a map key beneath it cannot leak either.
* **Semantic parser endpoint.** `POST /parser/{parserId}` was role-less; it now requires `eddi-admin`/`eddi-editor` and a `VIEW` check on the specific parser configuration.
* **Postgres health readiness.** The anonymous readiness payload no longer returns the JDBC URL or raw exception text — status only; the failure is logged server-side.
* **A2A `tasks/send`.** The JSON-RPC endpoint now requires a real role (`eddi-user` and up) rather than mere authentication, and the reply returns only the text output instead of the serialized `ConversationOutput` map.
* **`deleteConversationLog`.** Now uses the strict owner check, so a legacy no-owner conversation is not deletable (soft or permanent) by an arbitrary token. The strict check resolves the owner the same way as the soft-delete fix on `main` (live descriptor, else the archived one; no descriptor at all is a 404 unless admin), so a soft-deleted conversation can only be permanently deleted by its owner or an admin. A pre-v5.1.6 descriptor that records no owner (live or archived) takes its owner from the memory snapshot — the same fallback the listing uses — so the recorded owner can still delete their own legacy conversation; with no owner anywhere it stays admin-only.
* **Schedule delete status codes.** `DELETE /schedulestore/schedules/{id}` rethrows a downstream 403/404 instead of flattening it into a 500, like the other mutation paths.

### Tests

Focused unit tests added/extended, each mutation-checked (revert the fix, confirm the test fails, restore): `RestScheduleStoreTest`, `ScheduleOwnerScopeTest`, the Mongo and Postgres `ScheduleStoreTest` container tests, `ConverseWithAgentToolOwnershipTest`, `UserMemoryToolTest`, `RestAgentTriggerStoreTest`, `ConversationMemoryUtilitiesTest`, `ConversationAccessGuardTest`, `SecurityAccessControlAnnotationsTest`, `RestSemanticParserTest`, `A2ATaskHandlerTest`, `RestConversationStoreOwnershipTest`, `PostgresHealthCheckTest`.

**Files:** [`ConversationAccessGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/ConversationAccessGuard.java), [`ConversationMemoryUtilities.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java), [`RestScheduleStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java), [`UserMemoryTool.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/UserMemoryTool.java), [`RestA2AEndpoint.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/a2a/RestA2AEndpoint.java).

***

## 🔒 fix(slack): bind HITL decisions and event signatures to the owning integration; namespace Slack user ids (2026-09-26)

**Repo:** EDDI (`fix/security-channel-identity`)

### What changed and why

Three gaps in the Slack channel, each one a place where proving *who sent a request* was mistaken for proving *what it may act on*.

**1. A Slack HITL decision could resume any paused conversation or group.** The interactivity endpoint verified the signature and the approver list against the integration named in the button value, but never checked that the decision's subject (conversation id, or `group:<id>`) had anything to do with that integration. An approver of integration A who could get a signed `block_actions` payload carrying an edited value resumed conversations that belonged to integration B, to another channel, or to no Slack integration at all.

Every approval card is now recorded when it is posted, in a new persisted store (`ISlackApprovalRecordStore`, collection/table `slack_hitl_approval_records`, MongoDB and PostgreSQL), keyed by `(integrationName, subject, pauseEpoch)`. On a decision, `SlackInteractivityHandler` requires a live record for that integration and subject whose pause identity matches the subject's *current* pause (`hitlPausedAt` for a conversation, `GroupConversation.pausedAt` for a group). No record → refused and logged as `SLACK_HITL_DECISION_REFUSED`. A record from an earlier pause → the card is marked "already resolved" and the newer pause is not touched. A store failure fails closed and leaves the card intact for a retry.

The record replaces the in-memory `approvalNotified` cache as the "one card per pause" marker, so that guarantee now survives a restart too. It is written *before* the card is posted and removed if delivery fails. A card whose record cannot be written is posted without buttons. Group pauses got the same treatment: `HitlPauseEvent` now carries `pausedAt` (a 4-arg constructor keeps old callers compiling), and `SlackGroupDiscussionListener` records the card before posting it. Records expire after `eddi.slack.hitl.approval-record-retention` (default `30d`).

**2. `/integrations/slack/events` accepted a signature from any configured app.** It verified against the pooled set of every integration's signing secret, so a holder of one integration's secret could drive another integration's channels, agents and approval flow. After parsing, `RestSlackWebhook` now requires the signature to match the secret of the integration (or legacy connector) that owns `event.channel`. An owned channel whose owner has no secret is rejected, never re-admitted through the pool. For an unowned channel (a DM) the webhook finds which integration's secret signed it and passes that to `SlackEventHandler`, which routes the DM to *that* integration's default target through the new `ChannelTargetRouter.resolveDefaultForDm(type, text, integrationName)`. Previously it went to whichever integration came first in map order. Events with no channel at all keep the pooled check only, since the handler drops them anyway.

**3. Slack users shared the OIDC principal namespace.** The raw Slack user id (`U0ALICE`) was the EDDI `userId`. A Keycloak principal equal to a Slack id shared that user's long-term memories and passed ownership checks on their conversations, and ids from two workspaces could collide. It is now `slack:<team_id>:<user_id>` (`SlackUserIdentity`), with the team taken from the envelope's `team_id`.

Compatibility, with no migration step to run:

* A thread mapping stored under the raw id is found, **re-keyed** to the namespaced id, and the thread keeps its conversation. That conversation keeps its raw-id owner and its memories.
* A **new** conversation does not inherit long-term memories stored under the raw id; they are not moved (see the adversarial review section, Finding B).

### Design decisions

* **Persisted, not cached**, as the task required. It reuses the existing `approvalNotified` bookkeeping instead of adding a second marker.
* **`UNKNOWN_PAUSE` records** (card posted before the bookmark was readable) only match a pause that began at or before the record was written. A card cannot be for a pause that did not exist yet.
* **No memory migration at all** (neither move nor copy): see Finding B below.
* **Residual, documented:** checking the pause and resuming are two separate steps. Closing that window would need an expected-pause parameter on `resumeConversation`.

### Not done

* The approval records are not in the GDPR erasure cascade. They hold only an integration name, a conversation or group id and a channel id, and they expire on their TTL.
* Neither structured legacy memory entries nor the `IUserMemoryStore` Properties blob (`readProperties`) are migrated.

**Files:** [`ISlackApprovalRecordStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/hitl/ISlackApprovalRecordStore.java), [`MongoSlackApprovalRecordStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/hitl/MongoSlackApprovalRecordStore.java), [`PostgresSlackApprovalRecordStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/hitl/PostgresSlackApprovalRecordStore.java), [`SlackInteractivityHandler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackInteractivityHandler.java), [`SlackEventHandler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java), [`SlackGroupDiscussionListener.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java), [`SlackUserIdentity.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackUserIdentity.java), [`RestSlackWebhook.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java), [`ChannelTargetRouter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java), [`GroupConversationEventSink.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java), [`DataStoreProducers.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/datastore/DataStoreProducers.java). Docs: [`hitl.md`](/conversations-and-orchestration/hitl.md), [`slack-integration.md`](/protocols-and-integration/slack-integration.md).

### Item 4 — OpenAI-compat `X-OpenWebUI-User-Id` shared the OIDC principal namespace

`OpenAiAuthFilter` used the `X-OpenWebUI-User-Id` header verbatim as the EDDI `userId`. Since a leaked shared `/v1` key lets a caller set that header to anything, a caller could set it to an OIDC user's principal and reach that user's conversations and long-term memories. The header value is now namespaced to `openwebui:<id>` (`OpenAiUserIdentity`), so a self-asserted header can never equal a bare OIDC principal. OIDC principals (in `authenticated` mode) and the configured anonymous default are left unprefixed — the first is already verified, the second is operator config. The documented trust model is unchanged: a leaked key still impersonates any *Open WebUI* user; only the cross-namespace reach into OIDC-owned identities is closed (`application.properties` `trust-user-headers` comment still holds).

Compatibility is adopt-only, in `OpenAiConversationBridge`, only for the `openwebui:`-namespaced path, and **opt-in** (`eddi.openai-compat.adopt-legacy-header-mappings`, default `false`): a chat mapping stored under the raw header id is adopted and re-keyed to the namespaced id, and because the conversation keeps its raw-id owner its long-term memories load without any move. See the adversarial review section below for why the standalone memory move was removed, and the CodeRabbit section for why adoption is opt-in.

Test `OpenAiAuthFilterTest.headerUserId_isNamespaced_soItCannotEqualABareOidcPrincipal` proves an OpenAI-compat identity can never collide with a bare OIDC principal; mutation-checked (reverting the namespacing in the filter fails it). Adopt paths covered in `OpenAiConversationBridgeTest`.

**Files:** [`OpenAiUserIdentity.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/openai/OpenAiUserIdentity.java), [`OpenAiAuthFilter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java), [`OpenAiConversationBridge.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java).

### Adversarial review fixes (A/B/C)

An adversarial review of this branch found that the memory-migration I added in items 3 and 4 was itself exploitable, plus a name-collision hole in the item-1 binding. All three are fixed here.

* **A (BLOCK) — OpenAI-compat memory move was cross-namespace data theft.** In the default config a `/v1` shared-key caller sets `X-OpenWebUI-User-Id: <victim>`; `rawId` is then the fully attacker-chosen bare `<victim>`, which is exactly where OIDC principals keep long-term memories. The standalone "brand-new chat inherits" move called `getAllEntries(<victim>)`, upserted the result to the attacker's `openwebui:<victim>` and **deleted** it from the victim — one request relocated and erased an OIDC user's entire long-term memory. Fixed by **removing the standalone memory move entirely** (`OpenAiConversationBridge`). Only adoption of a conversation MAPPING under the exact intent remains — an OIDC principal never has an OpenWebUI mapping, and the adopted conversation keeps its raw-id owner so its memories load with no move. `IUserMemoryStore` is retained (unused for migration) so the no-migration invariant stays enforced by tests.
* **B (Should-fix) — same class in Slack.** `SlackEventHandler.migrateLegacyMemories` moved all bare-Slack-id entries keyed only on the raw id, and `team_id` is attacker-supplied in a validly-signed event, so a second integration's operator could pull a victim's legacy bare-id memories into their own `slack:<their-team>:<user>` namespace. Fixed by **removing the standalone move** (adopt/rekey keeps the raw-id owner, no move needed) and by **pinning `team_id` to the signing integration** where the webhook resolves it (`RestSlackWebhook.verifyOrigin` reads the owner/signer integration's `platformConfig.teamId`; the payload `team_id` is only a fallback when none is configured).
* **C (Should-fix) — HITL binding IDOR via non-unique display name.** The item-1 binding keyed on the integration's mutable display **name**, and names were not unique. An attacker could name their integration identically to a victim's, so `getIntegrationByName` might resolve to the attacker while the approval record belonged to the victim. Fixed fail-closed: `ChannelTargetRouter.getIntegrationByName` now **refuses (returns empty) when more than one integration matches** a name, and `RestChannelIntegrationStore` **enforces global name uniqueness per channel type** on create/update/duplicate (duplicate copies get a `" (copy)"` suffix). An ambiguous name therefore resolves to no owning integration and the decision is refused before any record lookup. (Unique-resource-id keying was considered but the fail-closed ambiguity refusal plus enforced uniqueness closes the IDOR with a much smaller blast radius on a security branch.)
* **D (nit) — pause-check → resume TOCTOU** in the item-1 decision path is left as a documented residual: closing it needs an expected-`pausedAt` argument threaded into `resumeConversation`/`resumeDiscussion` and a CAS there, which is disproportionate here. Noted in `SlackInteractivityHandler`.

All three fixes are mutation-checked (revert → the new test fails → restore): `OpenAiConversationBridgeTest.namespacedCaller_withNoLegacyMapping_neverTouchesBareIdMemories` (A), `SlackUserIdentityTest.newConversation_neverTouchesBareIdMemories` (B), `ChannelTargetRouterDeepBranchTest.duplicateNameRefused` (C). The approval-record store also gains real-MongoDB coverage (`MongoSlackApprovalRecordStoreTest`: `tryRecord` atomicity/idempotency/expiry/scoping via Testcontainers).

**Files:** [`OpenAiConversationBridge.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java), [`SlackEventHandler.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java), [`RestSlackWebhook.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java), [`ChannelTargetRouter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java), [`RestChannelIntegrationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java).

### Re-review cleanups (residual #1 + doc/nit)

The re-review shipped A/B/C but flagged that team\_id pinning was incomplete: when a signing integration declared no `teamId`, the webhook still fell back to the attacker-controlled payload `team_id`/event `team`, so an operator of any registered integration could craft a validly-signed event for an **unowned** channel (a DM) with a forged team+user and make the bot LOAD and echo that victim's long-term memories (read-only now that the erase is gone, but still a cross-tenant read).

Completed the pin (`RestSlackWebhook` + `SlackEventHandler.slackUser`):

* A signed event whose payload `team_id` or event `team` **disagrees** with the signing/owning integration's declared `teamId` is now rejected (403).
* The workspace comes only from the webhook-pinned `origin.teamId()`; the `event.get("team")` fallback in `slackUser` is removed.
* An **unowned** channel whose signing integration declares no `teamId` is treated as **unbindable**: the payload team is not trusted and the identity is team-less (`slack:<user>`), which reaches no victim's `slack:<team>:<user>`. (An owned channel with no declared `teamId` was first left trusting its payload `team_id`; that was closed in the CodeRabbit round below.)

Also: removed two now-unused test imports in `OpenAiConversationBridgeTest`, and corrected the `OpenAiUserIdentity`/`SlackUserIdentity` Javadoc that still described the removed "memories are moved to the namespaced id" behavior — both now describe the adopt-only reality.

Test `RestSlackWebhookTest.dmForgedTeamAgainstDeclaredWorkspaceIsRejected` (403) and `dmForgedTeamOnTeamlessIntegrationResolvesToNoVictim` (team-less identity) cover the residual; the second is mutation-checked.

### CodeRabbit review fixes (2026-09-28)

* **Owned Slack channel trusted the payload `team_id`.** With no declared `platformConfig.teamId`, an event in an owned channel took its workspace from the payload. The owner's signing secret proves the integration, not the workspace, so its holder could forge `team_id` + `user` and reach any `slack:<team>:<user>` — including users of integrations that *do* declare a `teamId`. The workspace now comes only from the declared `teamId`, for owned channels and DMs alike; without one the identity is team-less (`slack:<user>`). Residual, documented: all team-less integrations share that namespace, so `teamId` should be declared on every integration of a multi-workspace deployment (`docs/slack-integration.md`, which now lists `platformConfig.teamId`). Test `RestSlackWebhookTest.ownedChannelOnTeamlessIntegrationIgnoresPayloadTeam`, mutation-checked.
* **Legacy `/v1` mapping adoption could hand over an OIDC user's chat.** In `http-policy=authenticated` the bridge writes mappings under the bare OIDC principal, with the same intent. After a switch to shared-key mode, a caller sending `X-OpenWebUI-User-Id: <principal>` (and a matching chat key, which falls back to `default`) adopted that conversation — and through its owner, the user's memories. A raw mapping does not record its origin, so adoption is now opt-in: `eddi.openai-compat.adopt-legacy-header-mappings` (default `false`). Upgrading Open WebUI deployments whose `/v1` never ran in OIDC mode can enable it to keep in-flight chats on their conversations; otherwise such a chat starts a new conversation on its next message. Test `OpenAiConversationBridgeTest.namespacedCaller_doesNotAdoptARawMapping_byDefault`, mutation-checked.
* **A failed re-key dropped the legacy mapping.** When writing the namespaced mapping failed, the bridge deleted the raw mapping anyway, so the next request found neither. It now re-reads the namespaced mapping and deletes the raw one only when it points to the same conversation. Tests `rekeyFailure_keepsTheLegacyMapping` (mutation-checked) and `rekeyRace_dropsTheLegacyMappingOnceTheSameConversationIsConfirmed`.
* **OIDC principals could carry the `openwebui:` prefix.** An OIDC user named `openwebui:alice` was the same EDDI identity as the shared-key caller sending `alice`. `OpenAiAuthFilter` now refuses (401) an OIDC principal carrying the reserved prefix. Test `OpenAiAuthFilterTest.oidcMode_refusesAPrincipalCarryingTheReservedOpenWebUiPrefix`, mutation-checked.
* **Duplicate-name derivation scanned every integration per candidate.** It now reads the used names once; `validateUniqueName` stays the authoritative check.
* **Round 2.** A failed raw-id lookup during adoption now fails the request (retryable) instead of starting a conversation whose mapping would shadow the legacy one (`failedLegacyLookup_failsTheRequestInsteadOfShadowingTheLegacyMapping`). The channel-name uniqueness check refuses the save (503) when its scan cannot run, rather than allowing it (`uniquenessScanFailureRefusesSave`), and so does a transient failure reading one existing integration; only an integration that no longer exists, or whose document no longer deserialises, is skipped (`transientEntryReadFailureRefusesSave`). An adopted chat's conversation stays owned by the raw id, so GDPR export/erasure must address both `openwebui:<id>` and `<id>`; this is documented, not automated, because deriving the raw id could reach an OIDC principal's data.
* **Round 4 — Slack approval buttons are bound to their card.** Records were matched by `(integration, subject)` and the subject's current pause, but the clicked card was never identified, so an approver clicking an OLD card of the same conversation or group ("delete file A") while a NEW pause was live ("delete everything") approved the new action without seeing it. Each record now carries a random 128-bit card id, generated before the record is written and embedded in the buttons (`<integration>|<subject>|<cardId>`); a click is accepted only when its card id is on the record for the current pause. A button without a card id is refused. The id travels with the card, so there is no post-then-update window and no second write. Stores: MongoDB `cardId` field, PostgreSQL `card_id` column (added with `ADD COLUMN IF NOT EXISTS` to a table from an earlier build of this branch). Tests `SlackInteractivityHandlerTest.staleCardOfSameConversation_…`, `staleCardOfSameGroup_…`, `unboundLegacyValue_isRefused`, `unknownCardId_isRefused` (mutation-checked), plus `PostgresSlackApprovalRecordStoreUnitTest` and card-id round-trips in `MongoSlackApprovalRecordStoreTest`. Deferred: channel-name uniqueness is still read-then-write, so two concurrent creates can persist the same name; that fails closed (an ambiguous name binds no decision) and needs a name-claim collection on both backends to make atomic.

***

## 🔒 fix(security): close client-side leaks and framing/CSP gaps in the UIs (2026-09-26)

**Repo:** EDDI (`fix/security-frontend`) — backend, `ui/manager/`, `ui/chat/` and config together

### What changed and why

A batch of verified client-side security findings across the two frontends, one backend masking fix, and the deployment CSP.

**1. Secret-mode input no longer persists or echoes in plaintext (MEDIUM).** A turn flagged secret (🔒 secret mode, or a password `inputField` whose property scope was not `secret`) stored its raw text as public `input:initial` step data and replayed it to the client on reload; only the echoed `input` output was masked. `Conversation.scrubSecretUserInput` now rewrites `input:initial` / `input:normalized` and the echoed `input` to the `<secret input>` placeholder in the turn's `finally` — after the pipeline (so parser / property vaulting still see the plaintext transiently) and before the audit flush (whose `inputWasScrubbed()` keys off that placeholder to redact the recorded input). It also **drops the parser-derived forms** (`expressions:parsed`, `expressions:matches`, `intents`, `properties:extracted` and the `expressions` / `intents` outputs): the parser emits `unknown(<token>)` expressions embedding the normalized secret, which a free-text secret (API key / password) never matches out of, and which are persisted and shown in the admin raw-step view — scrubbing only `input:initial` left them behind. Mirrors `PropertySetterTask.dropParsedForms`. The Manager and Chat UI honour the placeholder so masking survives a reload. [`Conversation.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/Conversation.java), [`conversations.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/conversations.ts), [`snapshot.ts`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/api/snapshot.ts), [`secrets-vault.md`](/security-and-compliance/secrets-vault.md).

**2. Chat UI bearer-token handling hardened (MEDIUM).** A `?token=` bearer is now stripped from the URL on load (`history.replaceState`) so it cannot linger in history/Referer/logs, and a `postMessage` handshake from an allow-listed parent origin (`?tokenOrigin=`) is offered as the safer channel. Residual login-CSRF via `?token=` is noted for a follow-up PKCE/OIDC flow. `docs/security.md` corrected (the Chat UI has no `keycloak-js` login). [`ChatWidget.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/ChatWidget.tsx), [`security.md`](/security-and-compliance/security.md).

**3. Manager no longer auto-starts a conversation from URL params (MEDIUM).** `?agentId=` now only *preselects* the agent; starting is an explicit user action. `?agentName=` is ignored and the display name is resolved from the deployed-agents list. [`chat-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/chat/chat-panel.tsx).

**4. CSP + framing hardening (LOW).** `form-action 'self'`, `base-uri 'self'` and `object-src 'none'` added to every CSP (none fall back to `default-src`). A dedicated `/chat` CSP filter makes the widget embeddable via an operator-set `frame-ancestors` allow-list (`EDDI_CHAT_FRAME_ANCESTORS`, default `'none'`), and `X-Frame-Options` moved from a global header onto the default/Swagger filters so `DENY` holds everywhere except `/chat`. [`application.properties`](https://github.com/labsai/EDDI/tree/main/src/main/resources/application.properties).

**5. Group-transcript opt-in HTML no longer allows forms / inline style (LOW).** The DOMPurify HTML path forbids `form`/`input`/`button`/`textarea`/`select` and the `style` attribute. [`agent-response-card.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/agent-response-card.tsx).

**6. Markdown images no longer auto-load as live `<img>` (LOW).** All LLM/agent output markdown renderers in both UIs render images as links via a shared override, closing zero-click image exfil independent of CSP. [`markdown-safe.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/markdown-safe.tsx), [`MessageBubble.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/MessageBubble.tsx).

**7. Chat UI `?apiServer=` restricted to same-origin relative paths (LOW).** `sanitizeApiServer` is now an allow-list — the value must be a clean absolute path (single leading `/`, no control/whitespace/backslash). The previous deny-list was bypassable with a leading control char (`?apiServer=%09https://attacker`): the scheme/`//`/`\` tests missed the tab, but the WHATWG URL parser strips it and `${base}${path}` resolved to the attacker origin, leaking the bearer token. [`ChatWidget.tsx`](https://github.com/labsai/EDDI/tree/main/ui/chat/src/components/ChatWidget.tsx).

### Tests

Backend `ConversationSecretInputTest` (persisted `input:initial` is the placeholder, and a non-vacuous test drives the mocked lifecycle to emit `unknown(<secret>)` expressions and asserts the derived forms are wiped) and `CspPolicyTest` (form-action/base-uri/object-src present, per-path XFO, chat framing default `'none'`); Chat UI `snapshot`, `MessageBubble` and `ChatWidget.helpers` vitest specs (incl. the `?apiServer=` control-char bypass); Manager `chat-panel`, `agent-response-card` and `conversations` vitest specs. Key behavioural tests were mutation-checked (revert → red → restore), including the parser-derived-forms scrub.

***

## 🔒 fix(infra): close default-open deployment surfaces across compose, installer, GCP, Helm, Kustomize and CI (2026-09-26)

**Repo:** EDDI (`fix/security-infra`)

### What changed and why

A pass over the deployment / IaC / CI-CD surfaces found the `AuthStartupGuard` / `HighValueSurfaceGuard` protections were undercut by shipped defaults that pre-set the unauthenticated opt-outs and bound services to all interfaces, so the guards never protected a default deployment. Fixes, config/script/YAML only:

* **Compose bind to loopback.** [`docker-compose.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.yml) and [`docker-compose.postgres-only.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.postgres-only.yml) now publish EDDI on `127.0.0.1` by default with an `EDDI_BIND` override; exposing an unauthenticated instance off-box is now an explicit choice. Add-on overlays ([`chroma`](https://github.com/labsai/EDDI/tree/main/docker-compose.chroma.yml), [`ollama`](https://github.com/labsai/EDDI/tree/main/docker-compose.ollama.yml), [`nats`](https://github.com/labsai/EDDI/tree/main/docker-compose.nats.yml), [`monitoring`](https://github.com/labsai/EDDI/tree/main/docker-compose.monitoring.yml), [`openwebui`](https://github.com/labsai/EDDI/tree/main/docker-compose.openwebui.yml)) bind their published ports to `127.0.0.1`; Prometheus drops `--web.enable-lifecycle`; Grafana admin creds are overridable (dev default documented); the Open WebUI image is pinned off the rolling `:main`. `Dockerfile.demo` now runs as a non-root user.
* **GCP provisioner** ([`gcp/provision-vm.sh`](https://github.com/labsai/EDDI/tree/main/gcp/provision-vm.sh)): firewall rules are scoped to the caller's detected IP by default (`--source-ranges` to override, `--i-understand-public` to open to `0.0.0.0/0`); `create` refuses an open-access VM unless `--with-auth` or `--i-understand-public` is given; a random `KC_BOOTSTRAP_ADMIN_PASSWORD` is generated per VM (printed once) instead of `admin/admin`; the public Keycloak vhost blocks `/admin`; the success banner no longer prints stale `eddi/eddi` credentials.
* **Installer** ([`install.sh`](https://github.com/labsai/EDDI/tree/main/install.sh)): `.env`/`.eddi-config` created mode `600` before the vault key is written; `EDDI_BRANCH` derives from a pinned `EDDI_VERSION` (release tag) rather than always `main`, in the installer and the generated `eddi update` CLI; auth wizard clarifies the localhost-only default.
* **CI/CD** ([`.github/workflows/ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml)): a release tag build now fails unless the tagged commit is an ancestor of `origin/main`; cosign signs the pushed image **digest** rather than a tag; the cosign identity regex is tightened from `tags/.+` to the release-tag pattern (also in NOTES.txt, [`docs/release-signing.md`](/deployment-and-infrastructure/release-signing.md), [`docs/build-reproducibility.md`](/reference/build-reproducibility.md)). [`.github/workflows/auto-approve-copilot.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/auto-approve-copilot.yml) pins the auto-approval to the reviewed commit (`commit_id`) and skips PRs that touch `.github/**`.
* **Helm** ([`helm/eddi`](https://github.com/labsai/EDDI/tree/main/helm/eddi/README.md)): the chart refuses to render when `ingress.enabled && !oidc.enabled` unless `eddi.security.allowUnauthenticated` is set explicitly; `EDDI_MCP_ALLOW_UNAUTHENTICATED` / `EDDI_SECRETSTORE_ALLOW_UNAUTHENTICATED` are no longer derived from OIDC being off (default `false`, require explicit opt-in); NOTES.txt warns when `ingress.tls` is empty. In-chart MongoDB now runs with `--auth` and a required `mongodb.rootPassword`, its credentialed connection string moving to the projected Secret; NetworkPolicies isolate the MongoDB / PostgreSQL / NATS / Keycloak pods to the EDDI pod.
* **Kustomize**: PKCE `S256` added to the `eddi-frontend` client across all three realm copies ([compose](https://github.com/labsai/EDDI/tree/main/keycloak/eddi-realm.json), [helm](https://github.com/labsai/EDDI/tree/main/helm/eddi/files/eddi-realm.json), [k8s](https://github.com/labsai/EDDI/tree/main/k8s/overlays/auth/eddi-realm.json)); NetworkPolicies isolate the [MongoDB](https://github.com/labsai/EDDI/tree/main/k8s/overlays/mongodb/mongodb-networkpolicy.yaml) and [NATS](https://github.com/labsai/EDDI/tree/main/k8s/overlays/nats/nats-networkpolicy.yaml) overlays.
* **Container image** ([`src/main/docker/Dockerfile`](https://github.com/labsai/EDDI/tree/main/src/main/docker/Dockerfile/README.md)): application files are copied `--chown=root:0` so the runtime user (185) cannot overwrite its own jars; `USER 185` and the digest-pinned base are unchanged.

### Deliberately deferred / not changed (verified against current files)

* Keycloak Helm `KC_BOOTSTRAP_ADMIN_PASSWORD` stays `value: {{ required … }}` — a deliberate two-path design (`secretKeyRef` on kustomize, `required` value on Helm) that `DeploymentManifestsTest` enforces.
* Seeded `viewer/viewer` and `user/user` and `eddi-frontend` direct-access-grant are kept: the Auth E2E tier (`ui/manager/scripts/make-test-realm.mjs`, `e2e/auth`) depends on both, and the realm-drift tests require one shared realm.

***

## 🔒 fix(infra): review follow-ups on the loopback default and demo image (2026-09-26)

**Repo:** EDDI (`fix/security-infra`)

### What changed and why

Follow-ups from adversarial review of the change above:

* **The loopback default broke the GCP remote-deploy path.** With EDDI bound to `127.0.0.1`, the provisioner's health poll against `http://<external-ip>:7070` never succeeded and the advertised dashboard/API/MCP were unreachable on the VM. [`gcp/provision-vm.sh`](https://github.com/labsai/EDDI/tree/main/gcp/provision-vm.sh) now exports `EDDI_BIND=0.0.0.0` into the VM startup script (off-box exposure stays governed by the IP-scoped firewall and the auth/`--i-understand-public` gate); [`install.sh`](https://github.com/labsai/EDDI/tree/main/install.sh) exports and persists `EDDI_BIND` to `.env` so `eddi restart` keeps it. Monitoring (Grafana/Prometheus) stays loopback-bound on the VM and the success banner now says SSH-tunnel/localhost instead of falsely advertising the external IP; the pointless 3000/9090 firewall rule is dropped.
* **`Dockerfile.demo`** now pre-creates `/opt/eddi/data` (`chown 185:0`) before `USER 185`, mirroring the production image — a non-root process cannot create the audit dead-letter directory at runtime, and the miss silently drops audit entries.
* **Helm `NOTES.txt`** warns that the shipped realm seeds `viewer/viewer` and `user/user` with known passwords and that they must be disabled/re-passworded for production (removing the seed users stays deferred — the Auth E2E tier needs them; the tracked follow-up is to derive the E2E realm from a passwordless shipped realm and disable ROPC on `eddi-frontend`).
* **Cross-branch dependency:** the secrets branch adds a vault master-key strength gate that rejects the placeholder key [`docker-compose.openwebui.yml`](https://github.com/labsai/EDDI/tree/main/docker-compose.openwebui.yml) defaults to; that file now sets `EDDI_VAULT_ALLOW_WEAK_MASTER_KEY=true` (an opt-out that branch is adding; harmless as an unknown env var until it merges) so the demo keeps booting.
* **CI Deployment-Manifests render.** Making `mongodb.rootPassword` required broke the `manifest-lint` job's `helm template`/`helm lint` runs, which render the chart without it. [`.github/workflows/ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml) now passes `--set mongodb.rootPassword=<placeholder>` on every render that leaves MongoDB enabled (the default renders and the guard-failure cases alike, so each guard case still fails on the guard it tests, not on the missing password), mirroring how it already supplies the vault key and the PostgreSQL password.

***

## 🔒 fix(security): close SSRF, unbounded-download and resource-exhaustion gaps on outbound paths (2026-09-26)

**Repo:** EDDI (`fix/security-ssrf-outbound`)

Eight verified findings across the outbound-request and archive-import paths. Grouped by what they let an end user, the LLM, or a config author reach or exhaust.

### What changed

* **Unbounded response downloads (MEDIUM).** The attachment forwarder, web-scraper tool and PDF reader all buffered a whole response before checking its size, and the PDF path staged an unbounded file on disk first. A shared [`BoundedBodyReader`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/httpclient/outbound/BoundedBodyReader.java) now reads through a byte cap and a wall-clock deadline (the same shape as the crawler's `SafeHttpPageFetcher.readBounded`), exposed on `SafeHttpClient` as `sendValidatedBounded`/`sendBounded` returning a `BoundedResponse`. [`AttachmentForwarder`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/AttachmentForwarder.java), [`WebScraperTool`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/impl/WebScraperTool.java) and [`PdfReaderTool`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/impl/PdfReaderTool.java) use it, capped to the per-file/tool limits (`eddi.attachments.max-forward-bytes`, new `eddi.tools.web-scraper.max-response-bytes`, new `eddi.tools.pdf-reader.max-download-bytes`). [`AttachmentTextExtractor`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/impl/AttachmentTextExtractor.java) now loads PDFs with a temp-file scratch cache (PDFBox's default is unlimited in-memory) and stops at a page cap (`eddi.attachments.extraction.max-pages`, default 500) as well as the existing char cap.
* **httpcalls / A2A buffered the full body before the size check (LOW).** [`HttpClientWrapper`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/httpclient/impl/HttpClientWrapper.java) now streams the Vert.x response into a size-capped sink (`CappedBufferSink`) that fails the transfer mid-stream at `maxLength` instead of buffering then rejecting. [`A2AToolProviderManager`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/A2AToolProviderManager.java) reads the Agent Card and task responses through `BoundedBodyReader` at its 1 MB cap.
* **OpenAPI spec parsing fetched external `$refs` and read local files (MEDIUM).** [`McpApiToolBuilder.parseSpec`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpApiToolBuilder.java) now sets `parseOptions.setSafelyResolveURL(true)` so every `$ref` the parser follows goes through swagger-parser's blocked-URL resolver, and runs `rejectCloudMetadataTarget` on the spec location before fetching — `isValidHttpUrl` alone let `http://169.254.169.254/...` through. Stale Javadoc corrected.
* **Shared cookie jar across all users (MEDIUM).** [`HttpClientModule`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/httpclient/bootstrap/HttpClientModule.java) built one application-scoped `WebClientSession`, so a `Set-Cookie` from user A's httpcall was replayed on user B's call to the same host. It now uses a plain `WebClient` with no cookie store; `VertxHttpClient` and `HttpClientWrapper` updated to the plain type.
* **URL-validator range/family gaps (LOW).** [`UrlValidationUtils`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/UrlValidationUtils.java) now unpacks and re-checks every IPv6 embedding of an IPv4 address (IPv4-compatible `::/96`, NAT64 `64:ff9b::/96` and `64:ff9b:1::/48`, 6to4 `2002::/16`, Teredo `2001::/32` server and client), and blocks the missing IPv4 ranges `198.18.0.0/15`, the limited broadcast `255.255.255.255` and `192.0.0.0/24`, plus the Azure WireServer `168.63.129.16` and OCI `192.0.0.192` metadata endpoints. (The rest of `240.0.0.0/4` is deliberately left reachable — the shipped validator contract, pinned by `UrlValidationUtilsDeepBranchTest`, treats reserved-future-use space as allowed.)
* **Metadata guard missing on model/vector-store endpoints (LOW).** The OpenAI and Ollama language-model builders, `EmbeddingModelFactory` (Ollama), `EmbeddingStoreFactory` (pgvector, Elasticsearch, Qdrant, Chroma) and `AgentSetupService` (llmBaseUrl) now call `rejectCloudMetadataTarget` where the endpoint URL is built — a guard, not an SSRF lockout, so legitimate internal hosts stay reachable.
* **Redirect leaked a custom credential header (MEDIUM, ssrf-protection-off path).** [`ApiCallExecutor`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java) now disables redirect-following for any request whose header carries a connection, vault or caller credential — Vert.x strips only Authorization/Cookie/Proxy-Authorization cross-origin, so a custom `X-Api-Key` would otherwise survive a redirect to another host. `SafeHttpClient`'s cross-origin strip set gained the common custom-credential header names.
* **ZIP import had no bomb limits (MEDIUM).** [`ZipArchive.unzip`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/ZipArchive.java) now caps entry count, per-entry inflated bytes and total inflated bytes, counted as read (never trusting `entry.getSize()`), aborting with an `IOException`. Zip-slip protection unchanged.

### Tests

New: `UrlValidationUtilsAddressFormsTest` (thorough per-address-form table), `BoundedBodyReaderTest`, `HttpClientWrapperTest.CappedBufferSinkTests`, `ZipArchiveTest` bomb cases, `McpApiToolBuilderTest` spec-location cases, `ApiCallExecutorConnectionHeaderTest.RedirectFollowingWithCredentials`. Updated the mock plumbing in `AttachmentForwarderTest`, `PdfReaderToolTest`, `WebScraperToolExtendedTest` for the new bounded methods.

Mutation-checked (revert → the named test fails → restore) for findings 2, 3, 5, 7 and 8: five mutations, five killed. The `SafeHttpClient`-constructing tests (`PdfReaderToolTest.setUp` etc.) hit the known sandbox baseline "Unable to establish loopback connection" and are verified on CI; all pure tests pass locally.

### Follow-up (adversarial review round)

* **Redirect credential leak was wider than the reference-only fix.** Vert.x's default redirect handler copies every request header and removes only `Content-Length` (it does NOT strip Authorization/Cookie), so with ssrf-protection off a *literal* credential written in the httpcall config leaked cross-origin too — the reference-only `setFollowRedirects(false)` missed it. `HttpClientModule` now installs `strippingCrossOriginCredentials` on the shared client's redirect handler (composed with the existing metadata veto): it removes `SafeHttpClient.SENSITIVE_HEADERS` from any hop whose origin (scheme/host/effective-port) differs from the originating request, regardless of where the credential came from. `SafeHttpClient.SENSITIVE_HEADERS` is now public so both layers share one set. The false "Vert.x strips only the RFC three" comments in `ApiCallExecutor` and the stale buffering comment in `HttpClientWrapper` are corrected; the ApiCallExecutor reference-credential redirect disable stays as the stronger measure for resolved secrets. New tests in `HttpClientModuleTest` (cross-origin strip, same-origin keep, different-port, null-origin fail-safe, wrapper end-to-end).
* **A2A bounded read no longer passes a null watchdog.** `A2AToolProviderManager` gained a daemon `ScheduledExecutorService` and passes it to `BoundedBodyReader.read`, so a peer that sends 200+headers then stalls the body cannot hang the worker (the JDK request timeout does not bound `ofInputStream` body reads). Covered by a new `BoundedBodyReaderTest` case with a real scheduler and a stream that blocks until closed (`@Timeout(10)`).
* **Inline OpenAPI spec filesystem `$ref` closed.** `setSafelyResolveURL(true)` guards only URL-format refs; a filesystem-relative ref (`$ref: "/etc/passwd"`, `./x.yaml`, `file:…`) in inline content is resolved against the process CWD without the checker. `McpApiToolBuilder.parseSpec` now scans inline content and rejects any `$ref` that is neither an internal fragment (`#/…`) nor an `http(s)` URL. New `McpApiToolBuilderTest` cases.

Round-2 mutation checks (revert → named test fails → restore): cross-origin strip (3 tests killed) and the A2A watchdog (stalled-body test times out). Both restored.

### Follow-up (CodeRabbit review round)

* **Inline `$ref` guard now inspects the decoded document, not only the raw text.** A JSON or YAML escape in the key (a Unicode- or hex-escaped `$`) hid `$ref` from the text scan while swagger-parser still resolved it — so `"<escaped>ref": "/etc/passwd"` slipped past. `McpApiToolBuilder.rejectUnsafeInlineRefs` keeps the text scan as a first gate and then walks the tree built exactly as `OpenAPIV3Parser.readContents` builds it (`DeserializationUtils`, then the plain JSON/YAML mapper it falls back to), checking every decoded `$ref`. Content neither deserializer accepts is refused (the parser would reject it anyway). New `McpApiToolBuilderTest` cases for the escaped key in JSON and YAML, and an escaped internal `#/` ref that must still pass.
* **`WebScraperTool` flags truncated pages.** A body cut off by the size cap or the read deadline used to be decoded and handed to the model as if it were the whole page. Each tool now appends `[Note: the page response was truncated …; the content above is partial.]`, and a multi-byte UTF-8 character split by the cut is dropped rather than decoded to U+FFFD. New `WebScraperToolExtendedTest.TruncatedResponseTests`.
* The decision-log row on redirects now describes the design that ships (hop-level strip plus the reference-credential redirect disable), not the superseded first cut.

### CI fixups

* Documented the three new config keys in [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md) (`eddi.tools.web-scraper.max-response-bytes`, `eddi.tools.pdf-reader.max-download-bytes`, `eddi.attachments.extraction.max-pages`) — `ConfigurationReferenceCoverageTest` requires every read property to be written down.
* `HttpClientWrapper` gained a package-private `executePipedSend` seam so the streamed-body behaviour is unit-testable: only the Vert.x transport (which needs a live exchange) is stubbed, while `doSend`'s body encoding and the full `handleResponse` logic (Content-Length check, body-from-sink, size-cap → 503, header handling) run for real. `HttpClientWrapperSendBranchTest` rewritten to the streaming flow and now also asserts the sink actually caps an oversize body.

### Known residuals (documented, not fixed here)

* `WebSearchTool` and `WeatherTool` still use `SafeHttpClient.send(..., ofString())` unbounded; their responses are small API JSON, but they are not yet on the bounded path.
* `UrlValidationUtils` unpacks the common /96 NAT64 embedding; other RFC 6052 prefix lengths (/40, /48, /56, /64) place the IPv4 at different offsets and are not unpacked.
* `BoundedBodyReader`'s watchdog has a benign completion race (it may fire just as the read finishes); it is fail-safe — the worst case is a completed body reported truncated.

### Note for the merge

`engine/mcp/McpApiToolBuilder.java` is also edited on branch `fix/security-qute-engine` (template variable-name safety). This branch touched only the OpenAPI-resolution logic in `parseSpec` (and one comment in `buildApiCall` about the removed cookie session); the two changes are in different methods and should merge cleanly.

***

## 🔒 fix(templating): data is never rendered as a template; snippets are scoped to the agent's workspace (2026-09-26)

**Repo:** EDDI (`fix/template-injection`)

### What changed and why

Qute templates are for what an agent designer writes. Several paths also rendered *data*: text that a chat client, a user, an upstream API or an LLM controlled. Whoever controlled that data could write Qute and have the server evaluate it against the full template data model: `{vars.*}` (deployment-wide global variables), `{snippets.*}`, `{properties.*}`, or `{#for i in 2000000000}` and `{s.repeat(...)}` to pin a worker or exhaust the heap.

* **C4a — context-supplied output and quick replies.**
  * The problem: any `/say` context key starting with `output` (or `quickReplies`) of type `object` becomes agent output, and the templating task rendered it.
  * `OutputGenerationTask` now stores those entries with the new `IData#isVerbatim()` flag set, and `OutputTemplateTask` skips verbatim entries.
  * This also covers the output that an httpcall's `postResponse` build instructions produce. It travels the same `context:output` path and has already been rendered once, with the HTTP response substituted in.
  * The flag is **persisted** in `ResultSnapshot`, as `verbatim`, omitted from the stored document while false. A tool-call HITL resume reloads memory and re-enters the pipeline *after* the output task (review finding #2).
* **Rendered output is frozen (review #3).** After the templating task renders an output or quick reply, it marks the entry verbatim, and it marks its `:preTemplated` / `:postTemplated` twins verbatim too. An agent whose workflows each end with `ai.labs.templating` would otherwise render, in its second pass, whatever the first pass substituted in. An example is a property captured from user input that reads `{vars.apiKey}`.
* **C4b — `fromObjectPath` values.** `PropertySetterTask` and `PrePostUtils.executePropertyInstructions` sent a String resolved through `fromObjectPath` to the templating engine. Such a value is now stored as resolved. The authored `valueString` is still a template.
* **Sub-agent system prompts (review #8).** `CreateSubAgentTool` stored the system prompt that the parent *model* writes, which a chat user can steer, as a live template that `LlmTask` renders on every turn. The tool now wraps it in `TemplateEscaping.unparsedBlock`, so the rendered prompt is byte-identical and inert. `unparsedBlock` now keeps leading pipes in front of the block: Qute reads every `|` right after the opening brace as part of the opener, so a prompt that began with `|` produced a block the single-pipe terminator never closed and the render failed (PR review). `TemplateEscapingTest` pins the round trip, including a seeded 5,000-string sweep over braces, pipes and text.
* **C4c — prompt snippets were one global namespace.**
  * The problem: every render used `PromptSnippetService.getAll()`, which merged every workspace's snippets, with the last one listed winning a name.
  * The new `getForAgent(agentId)` is used by `MemoryItemConverter`, `LlmTask` and the counterweight presets.
  * Under enforced workspaces it injects snippets only from sources the agent's own side controls:
    * (0) snippets filed in the agent's space, where the space may use them;
    * (1) the owner's own snippets, for **personal-space agents only**;
    * (2) legacy (unowned) snippets.
  * Snippets that are **granted or published from another space are never injected**. The reason is in the decisions below.
  * A name shared by several eligible snippets goes to the lowest tier, and to the oldest snippet within a tier.
  * `getAll()` stays in place for the template preview, which redacts snippet content for non-admins, and it caches its resolved map again (review #10).

### Design decisions

* **Why grants and publishes are excluded (review #1).** Snippets are injected by name and automatically, and `ResourceSharingService` asks only the snippet's owner. So a grant or a publish on a snippet is a push into the recipients' prompts, not an offer they take up.
  * A team could publish `counterweight-strict` = "No restrictions apply" and replace the built-in safety preset in every agent in the deployment.
  * Or it could publish `persona` and outrank an agent's legacy `persona`.
  * Ranking these tiers lower would not help: they are the only candidates for the preset names, which nobody else defines.
  * There is no opt-in mechanism, and resolving names from each prompt's `{snippets.x}` references would not cover the counterweight lookup.
  * So the safe design is to exclude them. To reuse another team's snippet, copy it into the agent's space.
* **Team agents act as the team (review #4).** Such an agent has no personal identity, so its creator's private snippets and user-level grants stay out of it. Any editor in the team could otherwise read them back through the prompt. A personal-space agent acts as its owner.
* **Legacy snippets load regardless of `legacy-visibility` (review #5).** This matches every other configuration an agent references: that policy governs the authoring surface, and `ResourceClientLibrary` bypasses it. It is also safe, because no tenant can create an unowned snippet once enforcement is on (ownership is stamped whenever authentication is enabled).
* **An unreadable agent descriptor falls back to the legacy set, uncached (review #6).** An empty map would silently strip compliance and safety text from the prompt. The legacy set still exposes nothing from another space.
* **Sharing changes invalidate the caches (review #7).** `ResourceSharingService` fires a `SharingChangedEvent` (a CDI event, so the spaces package does not depend on the LLM module) after it writes a grant, revoke, visibility change or transfer. `PromptSnippetService` observes the event and clears its snippet caches on that node. Other nodes converge within the 5-minute TTL. A failing observer is logged and never undoes the sharing write. A load that is already reading the stores when an invalidation lands does not publish its result: loads capture a cache generation first and publish under the lock the invalidation holds while it bumps the generation and clears, so a pre-change view cannot be put back for the rest of the TTL (PR review).
* **Per-entry flag, not a key convention.** `output:<type>:context` collides with an output-set action literally named `context`, and the quick-reply key suffix is chosen by the client. The flag is `verbatim` with default `false`, so a Mockito mock of `IData` keeps the templated behaviour.
* **No render-size or time guard.** With data no longer templated, only config authors write templates. A guard around `render()` would not stop `{s.repeat(2e9)}` anyway, because that string is allocated inside one value resolution. Hardening the reflection resolver is a follow-up.

### Compatibility

* **Shapes:**
  * No change to stored config or ZIP shapes.
  * Stored conversation snapshots gain an optional `verbatim: true` on result entries. Older documents load unchanged.
  * The same additive field appears wherever the raw snapshot is served: `GET /conversationstore/conversations/{conversationId}`, `GET /agents/{conversationId}/approval-status?detail=full` and the MCP `get_approval_status` tool with `detail=full`. It is omitted while false. The simple conversation views and every other REST and MCP response are unchanged.
* **Behaviour:**
  * Context-supplied output and `fromObjectPath` strings containing `{...}` are now delivered literally. Neither behaviour was documented.
  * A sub-agent's `{...}` markers are now literal.
  * A second templating pass no longer re-renders.
* **Snippets, under enforced workspaces:**
  * An agent no longer gets snippets from other spaces, including granted and published ones.
  * A team-space agent no longer gets its creator's personal snippets.
  * With workspaces off, nothing changes except that duplicate names now resolve deterministically to the oldest snippet.

**Files:** [`IData.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/IData.java), [`Data.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/model/Data.java), [`ConversationMemorySnapshot.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java), [`ConversationMemoryUtilities.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java), [`OutputGenerationTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/output/impl/OutputGenerationTask.java), [`OutputTemplateTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/templating/OutputTemplateTask.java), [`PropertySetterTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/properties/impl/PropertySetterTask.java), [`PrePostUtils.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/apicalls/impl/PrePostUtils.java), [`CreateSubAgentTool.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/CreateSubAgentTool.java), [`PromptSnippetService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/PromptSnippetService.java), [`MemoryItemConverter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/MemoryItemConverter.java), [`LlmTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java), [`CounterweightService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/CounterweightService.java), [`ResourceSharingService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/spaces/ResourceSharingService.java), [`SharingChangedEvent.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/spaces/SharingChangedEvent.java), [`prompt-snippets-guide.md`](/agent-configuration/prompt-snippets-guide.md).

**Tests:**

* `ContextSuppliedOutputTemplatingTest`: real output and templating tasks on a real Qute engine, including a JSON persistence round trip and a double templating pass.
* New cases in `PropertySetterTaskTest`, `PrePostUtilsTest`, `CreateSubAgentToolHitlTest`, `ResourceSharingServiceTest`, `CounterweightServiceTest` and `MemoryItemConverterNamespacesTest`.
* `PromptSnippetServiceTest$WorkspaceScoping`, with one test per tier plus the grant, publish, legacy-vs-publish, team-vs-personal, admin-only-legacy, unreadable-descriptor and invalidation cases.

***

## 🔒 chore(security): allowlist the known gitleaks false positives of PRs #835, #838, #840 and #847 (2026-09-26)

**Repo:** EDDI (`chore/gitleaksignore-fix-prs`)

### What

The Secret Scanning job restores [`.gitleaksignore`](https://github.com/labsai/EDDI/tree/main/.gitleaksignore/README.md) from the base branch before it scans a pull request, so a PR cannot allowlist its own false positives: the entry only takes effect once it is on `main`. Four open PRs were red for that reason alone. This adds their fingerprints to `main` so their scan passes when re-run:

* **#835** (`fix/outbound-http-hardening`): a stand-in OpenWeatherMap key in `WeatherToolExtendedTest`, used to prove the operator's key never reaches the model or the log.
* **#838** (`fix/secret-scope-vault`): one fixture token in `PrePostUtilsSecretScopeTest` and `PropertySetterTaskSecretScopePathsTest`, standing in for a user-entered secret-scope value.
* **#840** (`fix/workspace-authz-scoping`): a 32-hex stand-in for an export archive key's random part in `RestExportServiceTest`. The block is byte-identical to the one #840 carries and sits at the end of the file, so the two merge without a conflict; the other groups are inserted above the PR #750 block for the same reason.
* **#847** (`fix/deployment-defaults`): four `curl-auth-user` hits in `install.sh`, where the installer probes Grafana with its factory default admin login in order to rotate it to a generated password.

Each flagged line was read and confirmed to be a test fixture or Grafana's published default, never a real credential. Local gitleaks, run per branch over `origin/main..<branch>` with the new ignore file, reports 0 findings for all four.

***

## 🔒 fix(security): engine-reserved context keys are no longer accepted from clients (2026-09-26)

**Repo:** EDDI (`fix/security-context-keys`)

### What changed and why

Several conversation context keys are written by EDDI itself when it drives a conversation on its own behalf — group member turns and delegated sub-agent conversations — and the engine then trusts them to decide what a turn may do: which group's shared memories are visible, which dynamic-agent policy governs the turn, which agents count as created by the conversation, and how deep a delegation chain already is. Those same keys were accepted verbatim in the context of any client request, so a caller could assert them and be believed.

* **New `ClientContextGuard`** removes the reserved keys (`groupId`, `groupConversationId`, `groupDepth`, `groupTranscript`, `dynamicAgentConfig`, `dynamicCreatedAgentIds`, `delegationDepth`) at every point where context crosses from a client into the engine: conversation start and turns over REST (plain and streaming, which also covers managed conversations) and the MCP managed-conversation path's trigger context. Internal callers reach `IConversationService` directly and keep setting them. The request still succeeds; only the reserved entries are dropped.
* **Group policy carries over.** A conversation governed by a group's dynamic-agent policy now keeps the most recent policy on a turn that carries no group context (such as one its owner sends into the member conversation directly), instead of falling back to the permissive standalone default. An entry that is present but is not a context entry resolves to the disabled policy rather than being skipped for an older or the standalone one.
* **`groupId` properties no longer select a memory scope.** The `usermemory` tool's group scope used to fall back to a `groupId` conversation property; properties are client- and input-settable, so only the orchestrator-injected context value counts now.

### Behaviour changes operators should know

* `{context.<reserved key>}` renders empty for a client-started turn. The Manager's Platform Operator drawer sends the viewed group as `groupId`, so its "(group …)" hint in the operator prompt is now blank; moving that hint to a non-reserved key is a follow-up for the Manager.
* A deployment whose callers are all trusted can re-permit specific keys with `eddi.conversation.client-context.permitted-reserved-keys` (default: none; a startup warning is logged when set).

**Files:** [`ClientContextGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/ClientContextGuard.java), [`RestAgentEngine.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java), [`RestAgentEngineStreaming.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestAgentEngineStreaming.java), [`McpConversationTools.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpConversationTools.java), [`DynamicAgentToolsProvider.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/DynamicAgentToolsProvider.java), [`ContextualToolsProvider.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ContextualToolsProvider.java), [`passing-context-information.md`](/agent-configuration/passing-context-information.md#reserved-context-keys), [`user-memory.md`](/architecture-and-concepts/user-memory.md#group-memory)

**Tests:** `ClientContextGuardTest`, `RestAgentEngineClientContextTest`, `McpConversationToolsReservedContextTest`, `DynamicAgentGroupPolicyCarryOverTest`, and `ContextualToolsProviderGroupIdTest` (property case inverted). Each was mutation-checked against a reverted fix.

***

## 🔒 fix(templating): restricted runtime template engine; data is never rendered as a template (2026-09-26)

**Repo:** EDDI (`fix/security-qute-engine`)

### What changed and why

EDDI renders Qute templates parsed at runtime from agent configuration. Two things about that path were hardened.

**1. Data is no longer treated as template text.** A template is what an author wrote; what reaches it at render time is data. This layer converged on the implementation that landed on `main` with #832 (`fix/template-injection`): `fromObjectPath` values are stored as resolved in both `PropertySetterTask` and `PrePostUtils.executePropertyInstructions`, and output and quick replies that arrive through context (caller-supplied, or built by a `postResponse`) carry #832's persisted `IData#isVerbatim()` marker, which `OutputTemplateTask` honours and also sets on every entry it renders. This branch originally carried its own per-turn marker (`isPreRendered`); it was dropped in favour of `isVerbatim`, which is a superset (it survives a HITL resume). This PR's own contribution is layer 2 below plus the MCP variable-name sanitizing; its data-path tests (`PropertySetterTaskDataIsNotTemplateTest`, `PostResponseDataIsNotRenderedTwiceTest`, now asserting `isVerbatim`) are kept as additional coverage of #832's fix.

Generated text that does end up in a template's source is kept literal: the system prompt a model chooses in `create_sub_agent` is stored inside an unparsed block (`TemplateEscaping.unparsedBlock`, also from #832), and `McpApiToolBuilder` reduces OpenAPI parameter names to plain identifiers before copying them into `{...}` placeholders (collisions get distinct suffixes; the query key itself keeps the spec's name). A path placeholder the spec does not declare as a parameter never takes a declared parameter's variable name — `{item-id}` beside a declared `item_id` becomes `{item_id_2}` and stays unfilled, instead of silently addressing the resource `item_id` names.

**2. Runtime templates use EDDI's own, restricted engine.** The Quarkus-injected engine is meant for build-time-validated application templates and exposes more than agent configuration should reach, including the `config:` namespace. `RuntimeTemplateEngineFactory` now builds a separate engine from it through allow-lists:

* namespaces: `vault`, `eddivault`, `connection`, `vars`, `caller`, `uuidUtils`, `json`, `encoder`, `str`, `time`; `config:`, `inject:` and `cdi:` resolve to nothing, and `str:eval` is dropped;
* sections: `if`, `for`/`each`, `let`/`set`, `with`, `when`/`switch` (no `include`, `insert`, `eval`, `fragment`, `cache`, user tags);
* `ReflectionValueResolver` replaced by `PropertyAccessValueResolver`: record components, public no-argument getters and public fields only — no method with arguments, no non-getter methods, no `getAndX` read-modify-write methods (`AtomicInteger.getAndIncrement` is getter-shaped but mutates), no reflection-sensitive types;
* a missing value always renders empty, independent of `quarkus.qute.property-not-found-strategy`;
* per-render bounds: `eddi.templating.max-output-chars` (default 2,000,000; output length and any single evaluated string) and `eddi.templating.max-iterations` (default 100,000; summed over nested loops, checked before a loop starts). `0` disables a bound.

The audit of shipped configs, `docs/agent-configs`, the docs and the tests found only features on these allow-lists in use (`?:`, `{#for}` with `_count`/`_index`, `{#if}`, `.size`, `.raw`, `{|…|}`, EDDI's string methods and `uuidUtils`/`json`/`encoder`, the pass-through references).

### Behaviour change operators should know about

* A property instruction whose `fromObjectPath` value happened to contain template syntax used to have it evaluated; it is now stored literally. Move any intended template into `valueString`.
* A runtime template using `config:`, `inject:`/`cdi:`, `{#include}`/`{#eval}`, or calling a method with arguments on a non-string object no longer resolves (namespaces render empty; unknown sections fail the render like any template error).
* Renders exceeding the new bounds fail like any template error.

### Tests

`RuntimeTemplateEngineFactoryTest` (engine built exactly as production builds it, from a source that carries a real `config:` namespace, `inject:`, `str:eval` and full reflection — each test asserts the feature is live in the source first), `PropertySetterTaskDataIsNotTemplateTest`, `PostResponseDataIsNotRenderedTwiceTest` (real `PrePostUtils` → `OutputGenerationTask` → `OutputTemplateTask` on a real memory), `RestTemplatePreviewTest$RestrictedEngine`, `McpApiToolBuilderVariableNameTest`, `CreateSubAgentToolPromptEscapingTest`, and `RuntimeTemplateEngineIT` against the running application (the only place Quarkus's generated extension resolvers exist). Each security test was mutation-checked against a reverted fix.

**Files:** [`RuntimeTemplateEngineFactory.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/templating/impl/RuntimeTemplateEngineFactory.java), [`PropertyAccessValueResolver.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/templating/impl/PropertyAccessValueResolver.java), [`BoundedLoopSectionHelperFactory.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/templating/impl/BoundedLoopSectionHelperFactory.java), [`TemplatingEngine.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/templating/impl/TemplatingEngine.java), [`application.properties`](https://github.com/labsai/EDDI/tree/main/src/main/resources/application.properties), [`McpApiToolBuilder.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpApiToolBuilder.java); docs: [`security.md`](/security-and-compliance/security.md#runtime-template-engine), [`output-templating.md`](/agent-configuration/output-templating.md), [`properties.md`](/architecture-and-concepts/properties.md), [`httpcalls.md`](/agent-configuration/httpcalls.md), [`configuration-reference.md`](/deployment-and-infrastructure/configuration-reference.md), [`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md).

### Known limits / next

* An OpenAPI `servers[0].url` containing braces still reaches the httpcall URL template; with the restricted engine it can no longer read anything, but it is not escaped.
* A model-written sub-agent prompt that itself mentions a `${vault:…}` reference renders with visible `{|`/`|}` markers around it (the known double-wrap limit of `LlmTask.escapeConfigReferenceMentions`); nothing is evaluated.

***

## 🔒 security(secrets): vault master-key, checksum, crypto and reference hardening (2026-09-26)

**Repo:** EDDI (`fix/security-secrets-crypto`)

### What changed and why

A pass over verified secrets/cryptography findings in the vault and related subsystems. Each item is a hardening; no configuration format changes for agent authors, and existing vaults keep working.

* **Master-key strength gate at startup.** [`VaultMasterKeyStrength`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/crypto/VaultMasterKeyStrength.java) rejects a weak or publicly-known vault master key (too short, too low-entropy, or a well-known demo/placeholder such as the `docker-compose.openwebui.yml` key). [`VaultSecretProvider`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java) fails startup in production and warns in development/test, mirroring `AuthStartupGuard`. A production opt-out `eddi.vault.allow-weak-master-key` (default `false`, mirroring `eddi.security.allow-unauthenticated`) downgrades the failure to a WARN so a brownfield deployment already running a weak-but-functional key can boot, rotate to a strong key via `POST /secretstore/secrets/admin/rotate-kek`, and then remove the flag — rather than being wedged (the key cannot be changed without a booted vault).
* **Keyed, tenant-bound secret checksum.** The plain unsalted `SHA-256(plaintext)` stored beside each secret is replaced by an HMAC over `tenantId + NUL + plaintext` ([`VaultChecksum`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/crypto/VaultChecksum.java)) keyed with a **random deployment key persisted KEK-wrapped** (encrypted with the KEK like a DEK, never in the clear), so it can no longer be brute-forced offline nor used to link equal values across rows/tenants. Because the key is wrapped rather than KEK-derived it **survives KEK rotation** — `rotate-kek` re-wraps it with the new KEK alongside the DEKs, so it unwraps to the same value before and after and every `h1:` checksum keeps verifying and a legitimate same-value re-setup does not spuriously fail. Versioned (`h1:` prefix) with a legacy bare-SHA-256 fallback, so existing rows keep verifying and migrate on next write. The checksum is no longer returned over REST. De-duplication and value-match go through `ISecretProvider.matchesChecksum` (the caller no longer holds the key).
* **Ciphertext bound to its row (GCM AAD).** Secrets are now sealed with `tenantId|keyName|dekId` as GCM Additional Authenticated Data, so a ciphertext cannot be swapped onto another key/row by someone with database write access. A no-AAD decrypt fallback keeps pre-existing rows readable with no migration.
* **Reserved namespace for agent signing keys.** Agents' Ed25519 private keys live under a reserved `agent-signing-key:` key-name prefix that [`SecretResolver`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/SecretResolver.java) refuses to resolve from a configuration or template, closing a path where an editor could exfiltrate an agent's own signing key via an httpcall header. The internal signing service still reads the key directly through the provider. Peer-verification logs an unsigned prior entry at ERROR under `requirePeerVerification`, and `docs/architecture.md` is corrected to describe peer verification as **detect-and-log, not a gate** (it does not yet drop/block unverified entries — see residuals).
* **Global variables that resolve to a secret are admin-only to write.** Because agent-secret grants are checked at deploy time, a non-admin editor could otherwise redirect a deployed agent's `${vars:key}` at an ungranted secret by editing the variable afterward. Storing a global variable whose value contains a `${vault:…}`, `${eddivault:…}` or `${connection:…}` reference now requires the `eddi-admin` role.
* **Sensitive-data leak fixes.** The export scrubber no longer lets a vault reference exempt a plaintext secret sitting beside it in the same value — the remainder is split on punctuation as well as whitespace (so `sk-live-…,` is still judged as a key), and in a credential-named field (`password`, an `Authorization` header, …) only an auth-scheme word such as `Bearer` and separators may sit beside the reference, so a short low-entropy password next to one is redacted too; Slack event, follow-up and group-discussion logging records message length only, at every level (no text preview, not even at DEBUG); and the pipeline task-error OpenTelemetry span carries the redacted audit summary and only the exception type, never the raw exception message.

### Migration / back-compat notes

* **Checksum:** legacy bare-SHA-256 rows keep verifying; new writes are keyed. The checksum key is a random deployment key persisted KEK-wrapped and **re-wrapped during KEK rotation**, so rotation does **not** invalidate stored checksums (verified by a KEK-rotation + same-value re-verify test). First use on an upgraded deployment lazily generates and wraps the key; it touches no tenant DEK, so it has no boot/store side effects. The key is created **only when the store confirms none exists**, through a new atomic `ISecretPersistence.setMetaValueIfAbsent` (Mongo `$setOnInsert` on the unique meta-key index, Postgres `ON CONFLICT DO NOTHING`): a failed read or a failed unwrap of an existing key (e.g. a KEK mismatch) now fails the operation instead of replacing the key, and racing nodes converge on the first key written rather than the last. There is no KEK-derived fallback key any more — a checksum written under one would be unverifiable later — and a store that keeps no metadata refuses to write a keyed checksum rather than use a key that would not survive a restart (legacy checksums still verify without the key).
* **KEK rotation:** `rotate-kek` now refuses, in production, a new master key the startup strength gate would reject (the `allow-weak-master-key` opt-out is for booting on a weak key to rotate off it, never for rotating onto one). The checksum key is unwrapped and re-wrapped during the verification phase, so a malformed or unwrappable one aborts the rotation before any DEK is written; and a failed write during the commit — any failure, including an unchecked store error, not only a `PersistenceException` — rolls the already-written DEKs back to the old KEK, so the vault stays readable under its configured master key and the rotation can be retried. Every rotation failure surfaces as `SecretProviderException`.
* **GCM AAD:** existing (no-AAD) rows decrypt via fallback; DEK rotation rebinds AAD to the new generation. The grant list is intentionally not part of the AAD so grant edits (which do not re-encrypt) keep values readable.

### Known residuals / deferred (not implemented here)

* **Audit HMAC key-id + per-deployment salt** (LOW): deferred. The `v4:` HMAC string format is pinned by tests and a per-entry key id would need an `AuditEntry` schema field; the most acute exposure (the publicly-derivable demo-key-derived HMAC key) is already removed by the startup master-key strength gate above. Recommended follow-up: encode a key id into the stored HMAC string and derive the key with a per-deployment random salt, keeping the fixed-salt key as the legacy verification key.
* **AAD downgrade tolerance** (LOW): the AAD decrypt path falls back to a no-AAD decrypt for legacy rows, so a legacy (no-AAD) ciphertext copied with its IV onto another row under the same DEK still decrypts there — exactly as every row did before this change. A per-row marker cannot close this, because it lives in the same database the attacker writes. The follow-up is an operator-controlled switch (configuration, not data) that disables the no-AAD fallback once every legacy row has been re-sealed with AAD — which DEK rotation already does.
* **Grant-list tampering** (LOW): `allowedAgents` is authenticated only at the deploy-time grant check, not bound into the ciphertext (binding it as AAD would break the no-re-encrypt `updateGrant`); a separate keyed MAC over the grant list is a future option.
* **Peer-verification hard enforcement** (#3b): still detect-and-log; dropping/blocking unverified entries needs a group input-path refactor. Docs now say so.

***

## ⏱️ feat(llm): per-tool execution timeout on the live dispatch path (2026-09-21)

**Repo:** EDDI (`feat/per-tool-execution-timeout`)

### Why

`ToolExecutionService.executeToolWrapped` is the single pipeline every tool call takes — built-in, http, MCP, A2A, dynamic, memory and recall. Its stages were rate limit → cache → **execute** → cost tracking, and the execute stage was a bare `toolExecution.get()` with no time bound of any kind. `LlmConfiguration.Task` had no per-tool timeout either. A tool that never returned held the whole conversation turn open indefinitely: no error, no metric, no recovery, just a turn that never ends.

The gap was **identified by an outside contributor in PR #574**, which was closed unmerged because it built on the reflection-based `executeTool(Object, Method, Object[], …)` path that `main` had already deleted as unreachable. The problem statement was right; the location no longer existed. None of that PR's code is reused here.

### What changed

* **`LlmConfiguration.Task.defaultToolTimeoutMs`** (new, `Integer`, default `120000`) and **`toolTimeoutsMs`** (new, `Map<String, Integer>`) — declared, defaulted and resolved exactly like the neighbouring `defaultRateLimit` / `toolRateLimits` pair, including the dispatch-name-before-slug key precedence that makes the documented slug form bind at all. `-1` (or `0`) means no bound.
* **`ToolLoopRunner.resolveToolTimeoutMs` / `defaultToolTimeoutMs(task)`** — the resolution table, sitting next to `resolveRateLimit`. Read off the `task` parameter rather than threaded down as yet another argument, the way `toolPricing` already is two lines above.
* **`ToolExecutionService.executeToolWrapped(ToolInvocation, …)`** takes a tenth argument, `int timeoutMs`, and wraps the execution stage in `executeBounded`. With no bound configured the supplier still runs **inline on the calling thread** — byte-identical to the previous behaviour on the path operators leave alone. With a bound it runs on one application-wide `newVirtualThreadPerTaskExecutor`, and the pipeline thread waits on the future.
* **On expiry the MODEL gets a result, not an exception**: `Error: Execution timed out after <n>ms for tool: <name>`, in the same shape as the existing `Error: Rate limit exceeded for tool: …` branch, so it can apologise, try another tool or answer without one. Nothing is cached and nothing is charged — both stages sit after the one that expired.
* **New meter `eddi.tool.execution.timeout`**, tagged `tool`, incremented alongside `eddi.tool.execution.failure` (the same pairing `ratelimited` already uses). Documented in [`metrics.md`](/deployment-and-infrastructure/metrics.md) and charted as target `E` of the "Execution outcomes" panel in [`eddi-full-metrics-dashboard.json`](https://github.com/labsai/EDDI/tree/main/docs/monitoring/eddi-full-metrics-dashboard.json). Joined by the gauge **`eddi.tool.execution.abandoned`** and its own panel — see "The abandoned worker" below.
* **Context travels with the call.** A bounded call leaves the pipeline thread, so the worker is wrapped with `CallerIdentityContext.propagate` (caller **and** resolution principal, the pairing that method exists to keep together), with `EddiToolBridge`'s thread-local conversation id, and with the OpenTelemetry `Context` so a tool's spans stay children of `eddi.pipeline.task` instead of becoming roots. Without the first of those, `${caller:token}` and `PER_USER` connections would have started failing closed inside every timed tool call.
* The `String` overload keeps its nine arguments and passes `TIMEOUT_DISABLED`: its one caller is `McpCallsTask`, whose tools are bounded by `McpCallsConfiguration.timeoutMs` (30s) and which has no `LlmConfiguration.Task` to read a timeout from.

**Files:** [`ToolExecutionService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/ToolExecutionService.java), [`ToolLoopRunner.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ToolLoopRunner.java), [`LlmConfiguration.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/model/LlmConfiguration.java), `EddiToolBridge.java` (since removed), [`langchain.md`](/agent-configuration/langchain.md), [`metrics.md`](/deployment-and-infrastructure/metrics.md).

### A timeout is never retried — and that is the point of returning a string

`RetryConfiguration.isRetryableError` walks the cause chain and calls any `TimeoutException` retryable. A timeout allowed to escape this method as an exception would therefore put a hanging tool on a retry loop: several more full waits, and several more chances to re-fire a side effect the tool had already begun. The internal signal is a private, stackless `RuntimeException` constructed **with no cause**, so no `TimeoutException` is reachable by the back door either, and it is consumed by its own catch block before the generic handler. `ToolExecutionTimeoutTest.timedOutToolRunsExactlyOnce` pins one execution attempt and asserts nothing propagates.

### A call awaiting human approval is not on the clock

Verified in the code and pinned by two tests rather than asserted.

The tool-approval gate runs in `ToolLoopRunner`'s loop body, **outside** `executeSingleToolCall`: `toolApprovalGate.classify(...)` splits the batch, the ungated calls execute, and then `ToolApprovalRequiredException` is thrown with the persisted `PendingToolCallBatch`. A gated call never reaches `executeToolWrapped`, so the clock — which starts when the supplier is invoked, inside that method — never starts for it. On resume, `ToolLoopResumer` runs each approved call through `executeSingleToolCallResult` → the same pipeline, with the timeout freshly resolved, timed from the moment it actually starts.

* `AgentOrchestratorToolPauseTest.gatedCallNeverEntersTheTimedExecutionStep` — a batch of one gated and one ungated call under `defaultToolTimeoutMs: 1`; exactly one execution reaches the service and it is the ungated one. Two-sided on purpose: a bare `never()` would pass just as happily if the loop had executed nothing at all.
* `AgentOrchestratorResumeToolLoopTest.resumeCarriesTheFullConfiguredTimeout` — the approved call is handed its whole configured budget, with nothing deducted for the wait.

### The abandoned worker: what a timeout does not do

`future.cancel(true)` interrupts the worker, so a tool blocked in interruptible I/O or a sleep unwinds at once. One stuck in a native call or a tight loop keeps running — and it still holds the same `IConversationMemory` the turn does, so **it can still write to conversation memory after the model was told the call failed**, and still complete an external side effect it had begun. Java offers no safe kill; the alternative on the table was not "stop it cleanly" but "wait forever". Said out loud in the class javadoc and in [`langchain.md`](/agent-configuration/langchain.md), with the remedy for a tool whose side effects must never be doubled: the HITL tool-approval gate, or `toolTimeoutsMs: {"thatTool": -1}` — not a shorter timeout.

Virtual threads rather than a pool for exactly this reason: an abandoned virtual thread parked in I/O holds no platform thread and no megabyte-sized stack, which is the difference between a hung tool being an incident and being a log line. One executor per application, created as a field initializer (unit tests construct the service directly) and `shutdownNow()` in `@PreDestroy`.

**Counted, not assumed away** (Copilot review, #817). Cheap is not free, and an operator who cannot see workers piling up cannot act on it. The gauge **`eddi.tool.execution.abandoned`** reports how many are still inside a tool that nobody is waiting for any more — normally zero, because `cancel(true)` unwinds anything interruptible, so a non-zero reading names the native-call case above. It is derived from two counters that are each incremented and decremented in a `finally` on the thread that owns them (`runningWorkers` inside the task body, `awaitedWorkers` around the `future.get`), so a worker cancelled before it ever started counts in neither and the gauge cannot drift; it clamps at zero rather than reporting the transient `-1` of a submit observed between the two reads. Deployment-wide rather than per-tool: which tool leaked is already answered by `eddi.tool.execution.timeout`.

The same review asked for an **admission bound** on the executor, refusing new calls once too many workers are stuck. Deliberately not done: that converts one tool's hang into a refusal of unrelated, healthy tool calls — a conversation-wide blast radius in exchange for a leak that is already strictly smaller than the unbounded wait it replaced. Left as a question for a human reviewer rather than settled in a follow-up commit.

### Verification

`.\mvnw.cmd compile` clean. New tests: `ToolExecutionTimeoutTest` (12), `ToolTimeoutResolutionTest` (8), five timeout cases in `AgentOrchestratorCoverageTest`, one each in `AgentOrchestratorToolPauseTest` and `AgentOrchestratorResumeToolLoopTest`. Final run: 357 tests, 0 failures, one surefire report per named class. Repo-wide guards run: `ImportStyleTest`, `DocumentationLinksTest`, `DocumentationAccuracyTest`, `ConfigurationReferenceCoverageTest`, `MetricsDashboardCoverageTest`, `ChangelogFragmentTest`, `BuildQualityGatesTest`, `StrictBoundaryShippedConfigsTest`, `RuleSetStoreShippedRulesetsTest`.

Eight mutations, each with its kill set predicted before the run and each killed by exactly the predicted named tests; sources restored byte-for-byte afterwards and re-run green.

| Mutation                                                         | Killed by                                                                                                                                                                                              |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Let the timeout escape as a real `TimeoutException`              | `timedOutToolIsAnsweredWithAnErrorResult`, `timedOutToolRunsExactlyOnce`, `timeoutIncrementsItsOwnCounterAndTheFailureCounter`, `timedOutCallIsNotCachedAndNotCharged`                                 |
| Drop `future.cancel(true)` on expiry                             | `expiryInterruptsTheWorker`, `timedOutToolRunsExactlyOnce` — and `fastToolIsUnaffected`, which went from 0.1s to 70s as the un-cancelled 30s workers saturated the carrier threads: the leak, measured |
| Treat `0` as "expire immediately" rather than "off"              | `disabledTimeoutRunsInline`                                                                                                                                                                            |
| Stop unwrapping `ExecutionException`                             | `toolFailureKeepsItsMessage`                                                                                                                                                                           |
| Look up the canonical slug before the dispatch name              | `dispatchNameWinsOverSlug`, `toolCall_dispatchNameTimeoutWinsOverSlug`                                                                                                                                 |
| Remove the engine-side default for a null `defaultToolTimeoutMs` | `nullTaskDefaultFallsBackToTheEngineDefault`, `toolCall_noTimeoutConfigured_usesTheEngineDefault`                                                                                                      |
| Never pass the resolved timeout to the pipeline                  | `toolCall_perToolTimeoutOverride_usesConfiguredTimeout`, `toolCall_timeoutKeyedOnCanonicalSlug_binds`, `toolCall_dispatchNameTimeoutWinsOverSlug`, `resumeCarriesTheFullConfiguredTimeout`             |
| Execute the gated calls before pausing for the human             | `gatedCallNeverEntersTheTimedExecutionStep`, and the pre-existing `gatedNotExecuted_ungatedExecutes`                                                                                                   |

***

## 🧹 fix(docs,manager): answer five review findings left unanswered on merged PRs (2026-09-21)

**Repo:** EDDI (`fix/merged-pr-review-followups`) — follow-ups to PRs #737, #739, #752 and #772, all long since merged, judged against `main` as it stands rather than against their diffs.

**What changed**

* **An avatar initial is one character again (PR #772).** `initialOf` in `ui/manager/src/lib/user-display.ts` upper-cased the matched letter and returned the whole result, but upper-casing can lengthen a letter: `"ß".toUpperCase()` is `"SS"` and the ligature `"ﬁ"` is `"FI"`. A user named ßeta Müller therefore got the three-character `"SSM"` in an avatar sized for two, and ﬁona ßauer would have got four. The function now keeps the first code point of the upper-cased result — `Array.from(...)[0]` rather than `[0]`, so the existing astral-plane case (`𝒜lice`) is unaffected. Three cases added to `user-display.test.ts`; mutation-checked against the old one-liner.
* **The NVIDIA GPU overlay is documented (PR #752).** `docker-compose.ollama-nvidia.yml` shipped naming itself in its own first line and appearing nowhere else in the repository — not the README, not a page under `docs/` — so the documented Ollama command did not enable GPUs and nothing said a second `-f` was needed. The README now carries the two-overlay command, lists the file among the available overlays, and states the two host prerequisites: **Docker Compose 2.30.0 or newer**, which is the release that introduced the `gpus` service attribute (older versions fail on it), and the NVIDIA Container Toolkit. The first draft of that sentence said the container "starts and Ollama quietly runs on the CPU" without the toolkit, which is wrong and was caught in review (Copilot, #819): `gpus: all` is a device *request*, so with no GPU driver registered the daemon cannot satisfy it and the container **fails to start** — `could not select device driver "" with capabilities: [[gpu]]`. Both the README and the overlay's own header now say that, and point anyone who wants CPU-only Ollama back at the plain overlay. The file itself gained the header every other overlay here has — including why it repeats the image tag (an overlay of a service the *base* file does not declare needs an image of its own, which `ComposeStackTest` enforces) and that the two tags must stay equal, because layering a lower one downgrades Ollama.
* **`ComposeStackTest` now sweeps the other direction.** It already checked that every compose file the README *names* exists; nothing checked that every compose file the repository *ships* is named anywhere, which is how the GPU overlay stayed invisible. README plus the pages under `docs/` count — that is where the Open WebUI stack and the MCP sidecar are explained — and the changelog does not, since an entry scrolls out into an archive nothing reads for current context.

  The first version *listed* `docs/` instead of walking it, which was also caught in review (Copilot, #819). It passes today, because every stack happens to be named in a top-level page; it would have started failing later over a file documented in `docs/monitoring/` or `docs/creating-your-first-agent/` — a false failure, which is how a guard gets deleted rather than fixed. It now walks the tree, excluding the changelog in all three of its shapes (`changelog.md`, the `changelog/` archives, the `changelog.d/` fragments) and `docs/archive/`. Two assertions pin the sweep's own shape rather than leaving it implied: that a nested page was reached at all, and that no changelog page was. Mutation-checked — with `Files.walk` put back to `Files.list`, the first of those fails and names the reason.
* **A rotation count in an archived entry is corrected (PR #739).** The 2026-08-25 merge note in `docs/changelog/2026-08.md` said the rotation "moved the 18 oldest entries"; the archive went from 179 to 187 top-level entries in that commit, and the same commit removed 8 from the live file. It says 8.

**Declined, with the reason recorded on the thread:** the markdownlint MD018 finding on PR #737 (two archived changelog lines that begin `#736 …`). MD018 is the reviewing bot's linter, not this project's — there is no markdownlint config and no CI step running it — and CommonMark requires a space after `#` for an ATX heading, so those lines render as ordinary prose with GitHub's autolink on the PR reference, which is what they are meant to be. Seven such lines exist across the archives; rewriting two of them would be churn on a historical record for a rule the repository does not adopt.

**Verification:** `mvnw compile`, `ComposeStackTest`, and the repo-wide guards (`ImportStyleTest`, `DocumentationLinksTest`, `DocumentationAccuracyTest`, `ChangelogFragmentTest`, `BuildQualityGatesTest`); in `ui/manager`, `npm run lint`, `npm run typecheck` and the `user-display` vitest file.

***

## 🐛 fix(manager): a past round and an approved resume, in the overview (2026-09-24)

**Repo:** EDDI (`feat/group-discussion-overview`)

Three findings from review, all confirmed against the code:

* **A past round read as still running.** `state`, `currentPhaseIndex` and the stream's convergence map all describe the newest round. When an earlier round was selected while a later one ran, its phases past the live phase index showed as "pending" even with turns in them, and a member who stayed silent showed as "pending" instead of "silent". An earlier round now counts as ended, reusing the existing terminal handling.
* **A past round showed the live round's convergence.** The digest read convergence by phase index alone. No record of an earlier round's check survives in the transcript or the stored document, so a past round now shows none rather than a wrong one.
* **Approving a paused discussion dropped its history from the Overview.** Approval clears the selection so the transcript follows the resumed stream, and that also disabled the stored-conversation query the Overview reads. The stream is seeded from none of the stored document, and after a reload the store holds nothing from before the pause, so the paused rounds' spend and every stance vanished until the stream settled. `group-detail.tsx` now snapshots the paused conversation at approval and feeds it to the Overview and the insights panel. It is used only while the stream is still on that conversation's id, so "New Discussion" or a fresh start can't surface a stale snapshot. It is a snapshot rather than a second query, because those figures change only through live frames the digest already overlays.

Four new digest tests and one page test cover these. All but the "live round still running" control fail with their fix reverted.

***

## 🐛 fix(manager): a resumed or continued stream starts from the whole discussion (2026-09-24)

**Repo:** EDDI (`feat/group-discussion-overview`)

A review follow-up turned up a larger defect underneath it.

* **Live continuations never recorded their round.** `/continue/stream` opens round 2 and later with `round_start`, never with `group_start`, and the stream handled only `group_start`. So a live continuation appended no question and never moved `roundStartIndex`. The Overview bucketed every round's turns into one round's phases, exactly the merge the round boundary was added to prevent, and the transcript showed no question for the new round. The existing tests modelled a continuation with `group_start`, a frame the backend does not send there, so they passed. `round_start` is now handled, and `RoundStartPayload` is typed.
* **A resumed or continued stream started from whatever the store held.** Neither endpoint replays the discussion so far. After a reload the store is empty, so the first resumed turn made the live transcript the only one, and the Overview lost every earlier phase and round. If the user had just watched a different discussion stream, the store still held that discussion's rows, costs and answer, and they appeared under this one. `approveAndStream` and `continueStream` now take the stored document as a seed, and a stream switching conversations starts from a clean state. The stored transcript wins unless the store is ahead of it, which happens when the page's copy was fetched before the last streamed rows. Both the Manager and the Workforce board pass the seed.
* **A resume that restarts at phase 0 re-announces its round.** Against a seeded transcript that would duplicate the question and count a round that never ran, so a question matching the transcript's last row is not appended again.

Six new stream tests. Each of the four changes (the `round_start` case, seeding, the duplicate guard, and "the store wins when ahead") was reverted in turn, and each revert fails at least one of them.

***

## 🐛 fix(test): RestImportServiceRagCronTest passes the source-policy constructor args (2026-09-24)

**Repo:** EDDI (`feat/group-discussion-overview`, a separate commit so it can be cherry-picked to `main` by itself)

### What

`main` stopped compiling its test sources. Two commits landed independently: `b8814bf8a` (fix(rag): arm ingestion schedules) added `RestImportServiceRagCronTest`, which builds a `RestImportService` with twelve constructor arguments, and `52c85736b` (fix(backup): make agent sync work more than once) added three more to the constructor: `requireHttpsSource`, `allowPrivateSources` and `allowedSources`. `52c85736b` updated every caller it could see. The new test was not one of them, because the two branches never saw each other before merging. Each commit was green on its own branch, and together they fail `testCompile`. That fails every unit test in the module, not just this one.

The test now passes `true, false, Optional.empty()`, the same production defaults its sibling tests use. It exercises cron arming only, so the source policy has no effect on it.

***

## 🐛 fix(memory): findings from a live end-to-end audit of the memory features (2026-09-22)

**Repo:** EDDI (`fix/memory-audit-findings`)

### Context

Every memory variation was exercised against a live instance of `main` (MongoDB, Ollama and Claude): REST, MCP, property scopes, visibility, the LLM memory tools, Dream, the rolling summary, windowing, strict write discipline, GDPR and the v5 migration. Each fix below reproduces a defect observed there, and carries a regression test.

### What changed and why

* **Upsert hijack (MongoDB).** A `self`/`group` write was keyed on `(userId, key, sourceAgentId)` with no visibility term, so it matched the `global` entry the same agent had created and flipped the shared memory to private — every other agent lost it. Reproduced through REST and through the `rememberFact` tool ("keep a private note" under a key once shared). The filter now excludes global documents, which is the identity PostgreSQL already enforced with its partial unique index. [`MongoUserMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/mongo/MongoUserMemoryStore.java)
* **Dream consolidation deleted every memory of a group.** Reproduced on Claude and Ollama: six unrelated facts in, zero out, fire reported `COMPLETED`. The model reused an original's key, the upsert overwrote that original in place, and "delete every original" then deleted the consolidation as well. Write targets are now resolved before anything is written: an original that receives a consolidated entry is kept, a key that would land on a memory outside the group (another category, another agent's global entry) skips the group untouched, duplicate keys in the model's answer are merged, and a failed insert restores the originals it overwrote. The answer is no longer truncated to `summarizeTargetEntries` — truncation dropped facts the model had preserved while the originals were deleted anyway; the target is now part of the prompt. An answer that only repeats some originals verbatim merged nothing, so it may drop an original only if that original duplicated a kept value; otherwise the group is skipped. Seen live on qwen2.5:3b, which returned three of four coffee preferences unchanged and lost "no sugar". An upsert that returns no id is treated as a failed write — the group rolls back, restoring any original with that key — instead of being counted as a created entry. No consolidated value is cut any more: a value over the length limit, whether the model wrote it or it came from joining two same-key values, keeps the group's originals instead. And a config whose `summarizationPrompt` is null or blank uses the built-in prompt, where it used to send the model the literal text "null". A consolidated entry that does not reuse an original is written with `insertIfAbsent`, so a memory another writer creates with the same key between the collision check and the write is refused atomically (and the group rolled back) instead of overwritten; only an entry that reuses one of the group's originals is upserted over it. `insertIfAbsent` now returns the new entry's id, which the rollback needs. PostgreSQL implements it atomically too (`INSERT … ON CONFLICT … DO NOTHING RETURNING id` against the existing unique partial indexes), where it had inherited the check-then-write default and, through `upsert`'s `DO UPDATE`, could still overwrite the concurrent entry. [`DreamService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java)
* **`scope: "secret"` values of different users shared one vault slot.** The slot was `<agentId>.<propertyName>`, so after Bob entered his API key Alice's conversation resolved to Bob's (reproduced live: both references identical, the vault checksum switched to Bob's value). Every auto-vaulted write now gets its own slot, `<agentId>.u<sha256(userId)[:16]>.<nonce>.<name>` — nothing can collide, and the user id itself never enters the vault. Since slots are no longer overwritten they are deleted explicitly: on permanent conversation deletion (single and retention sweep) — every slot the conversation's undo history and redo cache still reference, since undo restores an overwritten secret's previous reference — and on GDPR erasure, which sweeps the default tenant plus every tenant any of those versions point into and reports `autoVaultedSecretsDeleted`. A vault failure never discards the references: the conversation is kept (deletion fails, the sweep retries), and GDPR erasure keeps the snapshots and reports the step failed. The slot shape is reserved — the secrets REST API and setup's `vaultKeyName` reject it — because the GDPR sweep matches slots by name. Legacy shared slots are never deleted by this — they may still back other users' conversations. `ConfigReferenceGuard` accepts the new slot only when it is exactly this conversation's agent, user (hash) and property under its tenant; the legacy `<agentId>.<name>` form stays accepted for conversations vaulted before the change. [`AutoVaultedSecrets.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/secrets/AutoVaultedSecrets.java), [`PropertySetterTask.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/properties/impl/PropertySetterTask.java), [`RestConversationStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/rest/RestConversationStore.java), [`GdprComplianceService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/gdpr/GdprComplianceService.java)
* **A property's `visibility` was ignored.** `PropertySetterTask` and `PrePostUtils` built every property as `new Property(name, value, scope)`, so `"visibility": "self"` on a `longTerm` property was stored under the agent default — `global` for most agents, and agent B read agent A's "private" properties live. The instruction's visibility is now carried on every branch, and the inline `setOnActions` parser reads it too (an unknown value fails configuration instead of silently becoming global). [`PrePostUtils.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/apicalls/impl/PrePostUtils.java)
* **`userMemoryConfig` was dropped unless `enableMemoryTools` was on.** Its `defaultVisibility` and recall settings govern the `longTerm` property path of every agent, but the loader only kept the block for agents with memory tools — so `"defaultVisibility": "self"` on a rule-based agent was silently stored as `global`. The config now applies whenever it is declared; a new, separate `memoryToolsEnabled` switch (carried agent → properties handler → conversation memory) is what attaches the `UserMemoryTool`, so declaring a config never grants the cross-conversation write tool on its own. [`AgentStoreClientLibrary.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/client/agents/AgentStoreClientLibrary.java), [`ContextualToolsProvider.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ContextualToolsProvider.java)
* **`group`-visible `longTerm` properties were unreachable.** They were persisted with an empty `groupIds`, and recall matches group entries by group-id overlap — so nobody, the writing agent included, could read them. The persistence boundary now stamps the conversation's group (the resolver moved from the LLM module to `ConversationGroups` so both write paths share it); a `group` property in a conversation that belongs to no group is stored as `self`, the only scope that keeps it reachable without widening it. The group comes from the `groupId` context value only — the shared resolver carries main's removal of the `groupId` *property* fallback (clients and property setters can set a property, so it let a conversation claim any group), which now covers this persistence path and the undo/redo sync as well as the memory tool. When a key is visible twice — the shared `global` row and this agent's own scoped row, which coexist for instance after `defaultVisibility: self` starts writing a key an older version stored globally — loading now keeps the most specific one (`self`, then `group`, then `global`; the newest breaks a tie). The last one listed used to win, which for a recall ordered by update time was the older global value. The shared row is left untouched. [`Conversation.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/Conversation.java), [`ConversationGroups.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationGroups.java), [`UserMemoryEntry.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/model/UserMemoryEntry.java)
* **`autoRecallCategories` was never read — now documented as reserved.** Recall loads every visible category. It is deliberately *not* switched on: the store serializes the whole `userMemoryConfig` block, so the default `["preference", "fact"]` sits in every stored agent and is indistinguishable from an explicit setting — enforcing it would silently stop recalling `context`, `legacy` (migrated v5) and `property` entries for all of them.
* **The deprecated `maxSummarizationCalls` ceiling applied to every stored agent.** The getter wrote the default `10` into each saved agent, and reading it back called the setter, so after one save every config counted as having set the ceiling — the opposite of what `isMaxSummarizationCallsSet()` exists to tell apart. It is now serialized only when set. Agents already stored keep the `10` they were given; that bounds a dream cycle only past ten consolidated groups per user, which the per-category grouping rarely reaches. [`AgentConfiguration.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java)
* **The v5 → v6 properties migration overwrote newer data, on every restart.** It upserted each legacy value over whatever the user already had — reproduced live: a v6 `NEW-v6-value` became `OLD-v5-value` — and a single legacy document without a `userId` counted as a failure, so the source was never retired and the clobbering repeated at each boot. A key the user already has in `usermemories` as a **global** entry now keeps its value (which also keeps retries idempotent) — a self or group entry with the same key is a different memory and no longer suppresses the global one. The write is a new `IUserMemoryStore.insertIfAbsent`, atomic in MongoDB (`$setOnInsert` only), so a value another node writes meanwhile cannot be replaced either. An unowned document is skipped instead of failed — it stays readable in `properties_migrated_v6`. [`PropertiesMigrationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/mongo/PropertiesMigrationService.java)
* **`rememberFact`: corrections refused at the cap, budget spent on re-saves.** With `onCapReached: "reject"`, updating an existing fact was refused although an update adds no row (tool trace: `rememberFact(color, red)` → "capacity reached"). And models re-save facts they already stored — they do not see earlier tool calls — which spent `maxWritesPerTurn` on re-saves and refused every new fact, turn after turn (reproduced on Claude). The tool now resolves the write's target with the stores' upsert identity first: an unchanged re-save is reported as "already remembered" without writing or spending budget, an update never counts against the cap, and the limit message tells the model not to retry in the same turn. [`UserMemoryTool.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/UserMemoryTool.java)
* **Dream and the rolling summary could not select an Ollama (or Bedrock/Azure/Vertex) model.** `SummarizationService` always wrote the model as `modelName`; the Ollama builder reads `model` (Bedrock, HuggingFace and Vertex read `modelId`, Azure `deploymentName`), so every Dream cycle on Ollama failed with `model is required`. The model now goes under the provider's key, and an unset `llmModel` no longer overwrites the inherited model with `null`. [`SummarizationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/SummarizationService.java)
* **The documented Dream schedule was rejected, and a user-less one accepted.** The schedule JSON in `docs/user-memory.md` has no `message` — a Dream fire dispatches to `DreamService`, not to a conversation — and failed with "message is required for CRON triggers". Conversely a Dream schedule without a `userId` was accepted and then failed on every fire until it dead-lettered. Dream schedules no longer need a message and are rejected up front without a real user. [`RestScheduleStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java)
* **Dream could not detect the contradiction the docs describe.** "Agent A stored English, agent B stored German" was invisible by default, because the whole cycle was scoped to the firing agent's own entries. Detection is read-only, so it now considers every entry for a key the firing agent holds (a disagreement between two *other* agents is still not its to report) while pruning and summarization keep the ownership scope. Contradicting values are logged at DEBUG only — another agent's `self` memory does not belong in an INFO log. [`DreamService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java)
* **Strict write discipline: no same-turn fallback, a noisy digest, and a silent `keep_all`.** Reproduced live with a failing LLM task: every such turn returned an empty reply in `ERROR`, a rule on `task_failed_ai.labs.llm` could only answer on the *next* turn (and the one after failed again), the "concise" digest carried `dev.langchain4j.exception.ModelNotFoundException` and the provider's raw JSON body, and `keep_all` emitted no `task_failed_*` action at all although the documentation promises it whenever the discipline is on. New opt-in `strictWriteDiscipline.continueOnFailure` keeps running the remaining tasks after the failure is recorded, so an output keyed on `task_failed_<taskId>` — or an LLM task after a failed HTTP call — answers in the same turn; the default still stops the pipeline as before. The digest now strips exception class names and reduces a JSON error body to its `message` (a string `error` only when there is no `message`), and `keep_all` emits the action on top of the actions the task itself added. The rollback now restores the conversation output to its pre-task state — a key the failed task changed gets its previous value back, where only added keys used to be removed — so under `continueOnFailure` the failed task's output cannot reach the reply. [`LifecycleManager.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/lifecycle/internal/LifecycleManager.java)
* **Undo did not undo properties.** It popped the step and nothing else, so a slot filled by the undone turn stayed filled — in the conversation and in long-term memory (reproduced live: after undo, both a conversation-scoped and a `longTerm` property still held the undone value). Each completed turn now records its property changes on its step (`properties:changes`, uncommitted and non-public), which undo reverts and redo re-applies; `ConversationService` carries the `longTerm` part into the user-memory store, but only into the entry this turn wrote: same value, last written by this conversation, at the identity the turn persisted it under (the shared `global` row and the agent's own row can hold the same key, even the same value). A change made since by another conversation or agent is left alone, and so is anything ambiguous. The restored entry is persisted the way a turn persists it (`ConversationGroups.persistedVisibility`, with the deployed agent's `userMemoryConfig`), so undoing a visibility change undoes it in the store and a redone `group` property comes back as `group`. A new turn now also clears the redo stack: redo after *undo → new message* used to graft a step from the abandoned timeline. Turns completed through a HITL resume record no changes (undo leaves their properties as before). [`ConversationMemory.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationMemory.java), [`ConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/ConversationService.java)
* **REST and MCP memory writes: a 500, no limits, unreadable entries, all-required MCP args.** `PUT /usermemorystore/memories` without `visibility` crashed with a NullPointerException (HTTP 500); REST accepted a 1 MB value and any category; MCP accepted a 300-character key REST rejects; and both would store a `self`/`group` entry that no agent could ever read (no owning agent, no group). Both paths now share `UserMemoryWriteRules`: `visibility` required, `self`/`group` need a `sourceAgentId`, `group` needs `groupIds`, known categories only (`legacy`/`property` included), keys ≤ 255 and values ≤ 64 KiB of JSON; an absent category is stored as `fact`. MCP `upsert_user_memory` gained a `groupIds` argument, and the optional arguments of the memory tools are finally declared `required = false` — every one used to be advertised as required. [`UserMemoryWriteRules.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/UserMemoryWriteRules.java), [`RestUserMemoryStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/properties/rest/RestUserMemoryStore.java), [`McpMemoryTools.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/mcp/McpMemoryTools.java)
* **Recall and summary turn numbers were one too high.** Both labelled a turn by its output index
  * 1, and index 0 is the conversation's opening step — so the user's first message was "Turn 2" (reproduced on Claude), and a model asked for "turn 3" got something other than what the user meant. Turn N is now step N: the opening step is turn 0, the user's first message turn 1; ranges are inclusive, reversed ranges are normalised, and the opening step no longer renders an empty user line. A turn number beyond `int` means the last summarized turn instead of throwing `NumberFormatException` out of the tool, and a range ending at `Integer.MAX_VALUE` no longer overflows into an empty result. [`ConversationRecallTool.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/tools/ConversationRecallTool.java), [`ConversationSummarizer.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ConversationSummarizer.java)

### Documentation

`docs/user-memory.md` (what `enableMemoryTools` gates, `onCapReached`, re-save semantics, the upsert identity, group memory, write validation, the Dream schedule's `message`/`userId`, consolidation safety, cross-agent contradiction detection, the real `eddi.dream.*` metric names — the page listed `dream.*` — and the migration's skip rules), `docs/memory-policy.md` (`continueOnFailure`, a same-turn-fallback example, the digest's shape, `keep_all`'s action), `docs/conversation-memory.md` (undo/redo and properties), `docs/properties.md` (default visibility and group scope), `docs/secrets-vault.md` (per-write slots and their cleanup), `docs/langchain.md` (turn numbering) and `AGENTS.md` §5.3, which claimed every matching rule in a group fires — by default only the first one does (`executionStrategy: executeUntilFirstSuccess`).

### Known limitation, deliberately left

Memories written during the `CONVERSATION_START` turn carry no `sourceConversationId`: the conversation id is minted by the first store, after that turn has run. Pre-assigning it would change both conversation stores' "an id means update, and fail if the document is gone" guard, which exists for concurrent-erasure races; the field is provenance only (nothing reads it), so the trade is not worth making here.

***

## ✨ feat(groups): member stances and live cost/stance SSE for the discussion overview (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

A group discussion is readable as a transcript only while it is short. Seven members across five phases is \~40 messages of prose, and the questions an observer actually has — where are we, who thinks what, who disagreed, what has this cost — are answerable only by reading all of it. The Manager and the Workforce board both render that transcript and nothing else.

The planned answer is a second *view* of one discussion (not a cross-conversation metrics screen): a dashboard of self-hiding bands — phase rail, roster, members × phases matrix — that any discussion style can drive, because every style reduces to the same phases × members × entries shape underneath. This entry is the backend half.

Two things that view needs did not exist.

**Cost was invisible while it mattered.** `memberCosts`/`totalCost` live on the persisted `GroupConversation`, and no SSE event carried them — so a *running* discussion could show no spend at all, and the figure appeared only once the document was reloaded. That is exactly backwards: spend is worth watching while it accrues.

**There was no "where this member stands".** Reconstructing a member's position meant re-reading every turn they took.

### What changed

**`StanceSummaryEngine`** (new) — a one-line stance per member, recomputed at phase boundaries. Two producers, and which one ran is carried to the UI rather than hidden:

* **Lead-sentence extraction** — the default. No config, no cost, and it is the member's *own words*. `llmGenerated=false`.
* **LLM summarization** — opt-in via `stanceSummary.llmProvider`/`llmModel`. Reads everything the member has said. `llmGenerated=true`.

The distinction is surfaced because an extracted line is a quote and a generated one is a paraphrase; presenting the second as the first would be a misattribution.

A summarizer failure (throw, or a blank response) **degrades to extraction, not to nothing** — the roster is the band's headline content, and an empty roster is worse than a rougher one. Failures are WARN and never propagate: a display projection must not be able to fail a discussion.

Modelled deliberately on the I9 window summarizer — same provider/model pair, same optional price fields feeding the same I1 ledger via `GroupCostLedger.recordSystemCost`, same warn-don't-reject treatment at save time. A second piece of machinery that spends on the group's behalf should not be a second thing to learn.

**`StanceSummaryConfig`** has **no `enabled` flag**, unlike `ContextWindowConfig`. Stances always exist, so a boolean could only ever have meant "may this spend money?" — which is already what naming a provider and model means. A flag that could be `true` with no model would be two ways to say the same thing and one way to contradict it.

**Recomputation is skipped** for a member whose stored stance already covers the whole transcript. That skip is the difference between one summarizer call per member per *discussion* and one per member per *phase*.

**Two new SSE events:**

* `cost_updated` — fires after every attribution, including system spend. Carries the key's **cumulative** cost, not a delta: the ledger records by replacement so a duplicate is idempotent, and a delta would throw that away — a reconnecting client replaying one frame would double-count. A PARALLEL phase's turns can interleave, so `totalCost` may arrive out of order; a consumer keyed on `attributionKey` that sums its own map is order-independent, which is what the UI does.
* `stance_updated` — fires only when a stance's *text* actually changed.

`MemberTurnExecutor.announceCost` is called strictly **after** the ledger write returns. Emitting from inside `GroupCostLedger` would hold `memberCosts`' monitor across an SSE callback, letting one backpressured client stall every concurrent turn's attribution — the exact failure `announceArtifactChanges` already documents and avoids.

**`memberStances` on `GroupConversation` does not bump `CURRENT_SCHEMA_VERSION`.** The bump rule is scoped to fields "resume-time logic depends on", and this is a display projection: fully reproducible from the transcript, read by nothing in the discussion, refilled at the next boundary if empty. An older pod re-saving the document loses a cache, not state.

### Design decisions

* **Extraction is the default, not the fallback.** Opting in is opting into *spend*, not into the feature — so a deployment that never configures a summarizer still gets a populated roster.
* **`executeGroupMemberTurn` gained a listener parameter** rather than a parallel announce-queue. Its only production caller already had one in scope; the queue machinery `announceArtifactChanges` needs exists because tools hold no listener reference, which is not the case here.
* **Save-time warning only for a&#x20;*****half*****-configured summarizer** (one of provider/model, or prices with neither). Naming neither is the documented default and is not warned about.

### Tests

`StanceSummaryEngineTest` — 39 cases across extraction, entry-type selection, summarization, degradation, idempotence, `leadSentence`, `clean`, config normalisation and `announceCost`. Two mutation checks were run and both were caught by exactly the intended test: forcing the coverage skip off broke `unchangedMemberNotResummarized`, and adding `ABSTAINED` to the stance-bearing set broke `abstentionDoesNotReplaceRealStance` (a member's real position would otherwise be overwritten by "I have nothing to add").

**Files:** [`StanceSummaryEngine.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/StanceSummaryEngine.java), [`GroupConversationEventSink.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/lifecycle/GroupConversationEventSink.java), [`GroupConversation.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/model/GroupConversation.java), [`AgentGroupConfiguration.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java), [`MemberTurnExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/MemberTurnExecutor.java), [`GroupConversationService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java), [`AgentGroupStore.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java), [`StanceSummaryEngineTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/engine/internal/groups/StanceSummaryEngineTest.java)

***

## ✨ feat(manager): a dashboard view of a running group discussion (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

A group discussion is readable as a transcript only while it is short. Seven members across five phases is \~40 messages of prose, and the questions an observer actually has — where are we, who thinks what, who disagreed, what has this cost — are answerable only by reading all of it. All three surfaces rendered that transcript and nothing else.

### What changed

A second **view** of one discussion (not a metrics screen across discussions), offered by a `Transcript / Overview / Both` switch. The transcript stays the default and is never replaced — the node is passed through untouched, so approvals, human turns and the composer keep working exactly as before.

**Every style, one renderer.** A group discussion reduces to the same shape whatever its style: ordered **phases × members × entries**. So the dashboard is one set of bands driven by one adapter, and per-style difference is *band ordering*, not a code path (`style-recipe.ts`). A style this build has never heard of falls through to the default order and renders sensibly rather than blankly.

The bands, each hiding itself when it has nothing — the pattern `DiscussionInsights` already set, so a caller can mount the panel unconditionally:

* **Headline** — question, state, round, elapsed, phases done, members, turns, cost
* **Phase rail** — the spine: each phase's status, a dot per member who has spoken, the convergence score where one was judged, and an approval-gate marker
* **Roster** — "who thinks what": each member's one-line stance, turn count, spend and whether they are speaking, dissenting or broken
* **Matrix** — members × phases, the band that does the actual compression
* **Outcome / extras** — slots filled by the components that already exist (decision card, task board, negotiation ledger, artifacts)

**`useDiscussionDigest` is the load-bearing piece.** Three independent transcript renderers exist here, fed by two different shapes (live `GroupStreamState`, persisted `GroupConversation`), and a group feature wired into only some of them has already drifted once — a DISSENT rendered as an ordinary opinion on two of the three. One adapter means the dashboard cannot acquire that class of drift: exactly one place knows how a live discussion differs from a reloaded one.

### Design decisions

* **`absent` and `pending` are different cells and must not collapse.** `absent` means the phase's selector never included that member (a MODERATOR-only synthesis, a `ROLE:PRO` rebuttal); `pending` means they are expected and have not spoken. Drawing both blank tells a reader that a debate's PRO side went quiet during a CON phase. A first cut rendered every not-yet-reached phase as `absent` — i.e. "excluded" rather than "not yet" — which the digest tests caught.
* **The participant selector is only resolved when it says `ALL`.** `MODERATOR` and `ROLE:…` need the roster, which not every surface has, so they resolve to *unknown* and the matrix falls back to `pending`. Guessing the other way silently hides a member who was expected.
* **Total cost is summed from the per-key map, never read off a frame.** PARALLEL turns interleave, so frame order is not value order; and a member's spend can span several ledger keys (a nested GROUP member gets one per child discussion), matched on a `:` boundary so `agent-a` does not absorb `agent-abc`.
* **No cost is `null`, not `$0.00`.** An unpriced LLM config reports nothing at all, and "$0.00" reads as "this was free" rather than "this was not measured", so the stat hides.
* **Whether a stance is a quote or a paraphrase is shown, not flattened** — different icons and different labels. Presenting an LLM paraphrase the way a quotation is presented would misattribute it.
* **DELPHI's roster is anonymised.** Its method is that members judge the argument, not its author; naming everyone next to their position would undo that for the one human watching.
* **`split` is a layout, not a capability.** It is a pure container query, so narrowing the pane restacks the two panels instead of discarding the half the user chose — and the stored preference never silently changes to something they did not pick. Which element scrolls changes with width, because a stacked transcript whose parent is auto-height collapses to nothing (`flex-1 min-h-0` resolves to zero).
* **Sized by container query throughout**, not viewport: this renders in a column the group config panel can squeeze to half the window, which `task-board.tsx` already records getting wrong the other way.

### Tests

`use-discussion-digest.test.ts` (30) over phase derivation, the matrix's five cell kinds, cost attribution and source precedence; `discussion-overview.test.tsx` (26) over the bands, the style recipe, the stored view preference and the panel's switching. Full suite **6682 passing (419 files)**, lint and `tsc -b` clean, i18n drift + plural-completeness + translation-debt gates green across all 11 locales.

Three mutation checks, each caught by exactly the intended test: dropping the `:` boundary from cost-key matching, forcing `rosterIsAnonymous` false, and the `absent`/`pending` collapse (which was a real bug, not a seeded one).

**Files:** [`use-discussion-digest.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-discussion-digest.ts), [`discussion-overview.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/discussion-overview.tsx), [`discussion-panel.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/discussion-panel.tsx), [`phase-rail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/phase-rail.tsx), [`member-roster.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/member-roster.tsx), [`participation-matrix.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/participation-matrix.tsx), [`style-recipe.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/style-recipe.ts), [`group-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/group-detail.tsx), [`workforce-board.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workforce/workforce-board.tsx), [`conversation-viewer.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/workforce/conversation-viewer.tsx)

***

## 🐛 fix(groups): review remediation for the discussion overview (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

A high-effort adversarial review of the two commits above found eleven defects worth fixing, five of them producing **wrong numbers** and four making the dashboard **assert things that are not true**. Each is recorded here because most were invisible to the tests that existed — several were guarded by assertions that would have passed with the bug in.

### Wrong numbers

* **The documented "a silent member costs nothing" skip was never implemented.** Coverage was keyed to the *transcript length*, which grows whenever anyone speaks, so the skip only fired for a phase nobody spoke in. A six-member discussion re-summarised all six at every boundary — the exact "one call per member per phase" the Javadoc, the docs and the changelog all claimed to avoid. `MemberStance.upToTranscriptIndex` is now `coveredContributions`, counting that member's own stance-bearing entries.
* **The stance summariser escaped the I1 cost ceiling.** Every other optional spender (the I9 window summariser, the convergence judge, the dissent round) checks `wouldExceedCeiling`; this one did not, so a boundary ran one priced call per member *after* the budget was gone and before the next phase's pre-wave check could fire. Past the ceiling it now downgrades to extraction — `wouldExceedCeiling`, not `enforceCeiling`, because declining optional work is not the same event as a phase running out of budget.
* **A re-summary landing on the same wording billed the ledger and emitted no `cost_updated`.** Results were reported on text change alone; they are now reported when the text changed *or* the call cost something.
* **The I9 window summariser's spend was never announced**, though four places (two Javadocs, the docs and the changelog) said system spend is emitted "after every attribution". Any windowed discussion's live total sat below the ledger's for its whole run. `updateWindowSummary` now returns the ledger key it billed, for the caller to announce.
* **The live cost map replaced the persisted one instead of overlaying it.** A stream carries only the keys it announced *this session*, so pressing Continue on a $4.10 discussion made the headline read $0.02 until the document was refetched. Now merged per key — correct precisely because each frame carries that key's *cumulative* cost.

### The dashboard stating something false

* **Continuation rounds were conflated.** The backend restarts `phaseIndex` at 0 each round, so bucketing the whole transcript by phase index put round 1's turns in round 2's cells, let a round-1 ERROR mark a round-2 cell "failed", and showed members as having already spoken in phases the current round had not reached. The digest now slices at `roundStartTranscriptIndex`.
* **`leadSentence` returned `"1."`** for the numbered list LLM replies open with constantly — rendered as that member's position, attributed as *their own words*. A candidate sentence containing no letter is now rejected. Conversely the abbreviation rule swallowed `"Weigh option B. Option A is worse."`, so it now also requires a lower-case continuation.
* **Extraction quoted JSON.** `VOTE`, `BID`, `RETRO`, `PLAN`, `TASK_RESULT` and `VERIFICATION` carry a JSON contract; the lead "sentence" of a ballot is `{"choice":"pgvector",` — and being the newest entry it *replaced* the member's real prose position after every vote or retro. Excluded from extraction on both sides; the summariser still reads them.
* **"Has not spoken yet" was shown beside a turn count.** A moderator's only contribution is a SYNTHESIS, which is not a position of its own, so it had no stance while plainly having spoken. Now two messages.
* **A dropped turn was labelled "not in this phase".** `absent` means the selector excluded the member; a finished `ALL` phase with nothing from them means their turn was dropped (`maxTurns`, a ceiling, a `SYNTHESIZE_NOW` jump). New `silent` cell kind, shown only where the selector is *known* to have included them.
* **The headline's "Turns" counted rows the rail excluded** (QUESTION, CONVERGENCE, FACILITATION, system SKIPPED), so the two disagreed. Same filter now.

### Also

* **A vacuous negative assertion.** `verify(never()).summarizeWithUsage(anyString(), …)` could not fail: a half-configured summariser passes a **null** model, and `anyString()` does not match null. Replaced with `verifyNoInteractions`.
* **Dead API surface removed** — `DigestPhase.expectedSpeakers` (always null), `streamTotalCost` (no consumer), and the `onSelectPhase`/`onSelectMember`/`onSelectCell` props no surface passed. The phase interaction that was worth keeping is now owned by `DiscussionPanel` itself (picking a phase switches to the transcript, where turns are) rather than being an optional callback nobody supplied — a click target that silently does nothing is worse than none.
* **An orphaned Javadoc**: the new save-time warning had been inserted between `warnOnSummarizerlessWindow`'s doc comment and its body.
* **Literal NUL bytes** were in `use-discussion-digest.ts` (a matrix key separator written as a raw character rather than an escape), which made `file` report the source as binary.
* The question in the headline is now **clamped to three lines**. `originalQuestion` is not always a question — the grant-board fixture pastes an entire application — and rendering it whole pushed the rail, roster and matrix below the fold, which is the wall of text this view exists to replace. Found by running the UI, not by a test.

### Tests

Backend 48 (up from 39), frontend 42 in the digest suite (up from 30). Four further mutation checks, each caught by exactly the intended test: dropping the round slice, swapping the cost overlay back, and (backend) the per-member coverage skip and the ceiling gate.

**Files:** as the two entries above, plus [`GroupContextBuilder.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java).

***

## 🐛 fix(groups): second review round — continuation rounds, missing bands, per-member ceiling (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

A second reviewer (Copilot) found eleven further defects on the branch, clustered in three places the first round had touched but not finished: **continuation rounds**, **content the Overview simply did not render**, and the **cost ceiling**. All eleven were confirmed against the code before being fixed.

### The ceiling and the cost/stance events

* **The budget was decided once per boundary, not per member.** If the first member's call pushed `totalCost` past the ceiling, every remaining member still took the LLM path — so a boundary could add N paid calls after the budget was exhausted. Re-checked before each member now.
* **`stance_updated` fired when nothing had changed.** Fixing the earlier "cost with no frame" bug had put cost-only results into the same list the stance event iterates, so every paid re-summary announced a stance change that had not happened. `StanceResult` now carries `textChanged`; the stance event keys off it and the cost event off `cost()`, because the two are genuinely independent.
* **`clean(text, 1)` returned two characters**, violating its own documented hard cap — one retained character plus the ellipsis. A cap of 1 now yields the ellipsis alone.

### Continuation rounds, again

The first round's fix sliced the *persisted* transcript at `roundStartTranscriptIndex` but forced the offset to zero while live — on the assumption that a continue-stream starts at the round boundary. **It does not.** `continueStream` deliberately preserves `s.transcript` and the `group_start` handler *appends* the new question, so a live continuation carried every round in one array and re-created exactly the cross-round contamination the slice exists to prevent. `GroupStreamState` now tracks its own `roundStartIndex`, set at `group_start`.

**The headline also showed the wrong question.** A continuation records its follow-up as a `QUESTION` entry rather than rewriting `originalQuestion` (which stays the title), so round 2+ displayed the *first* round's prompt above a summary of answers to a different one. The digest now resolves the newest `QUESTION` in the current round, falling back to `originalQuestion`.

### Content the Overview did not render

* **No synthesised answer.** The outcome band rendered only the caller's node, and all three callers pass a decision card. Most ROUND\_TABLE and PEER\_REVIEW runs produce no structured decision — so an ordinary completed discussion showed **no conclusion at all** in Overview, reachable only by switching back to the transcript. Now rendered as its own card, *alongside* a decision rather than instead of it: the decision carries the tally and minority report, the synthesis carries the reasoning.
* **No task board, on any of the three surfaces.** It lives inside the transcript renderers, which Overview mode unmounts — so TASK\_FORCE, the one style whose recipe puts `extras` first, lost its principal working surface. Added to all three `extras` nodes, reusing each surface's already-computed live/persisted/placeholder state rather than deciding again.
* **Members silent in the current round disappeared.** Roster-only members were collected from the optional `rosterDisplayNames` prop alone, but the Workforce history viewer passes none — so a member with no turn this round vanished from the roster *and* the matrix instead of showing `silent`/`absent` cells. Now taken from the merged display-name map, which includes the persisted `memberDisplayNames`.
* **`group-detail` nulled the conversation while streaming**, copying what the transcript needs — which defeated the persisted/live cost overlay the previous round had just introduced. `continueStream` seeds neither its cost nor its stance map from the stored document, so a continuation dropped the earlier round's spend and positions. The panel now always receives the persisted document; only the transcript keeps the nulling.

### Verified by running it

The Overview was re-opened in the Manager's mock mode after the fixes. The Conclusion band is present where there was previously nothing, the matrix rows align, the moderator reads "No position of their own" rather than the false "has not spoken yet", and the headline's turn count now agrees with the rail (7, not 8 — the `QUESTION` row is excluded on both sides).

### Tests

Backend 748 across the group suites and repo guards; frontend 76 in the two overview suites, with new cases for the live round boundary, the current-round question, the silent-member roster entry, and the synthesis band's three states.

**Files:** as above, plus [`use-group-discussion-stream.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-group-discussion-stream.ts).

***

## 🐛 fix(manager): the overview was losing the mechanics of its two most-used styles (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

Auditing the dashboard against every discussion style turned up information the digest *collected and then never rendered* — the same dead-surface class two reviewers had already flagged elsewhere, but here it cost the reader real signal rather than just carrying an unused field.

* **A repeating phase looked identical to a single-pass one.** `ROUND_TABLE` puts `repeats = rounds - 1` on its "Discussion" phase and `DELPHI` is built on repeats end-to-end, so for the two most-used styles the rail was quietly flattening the mechanic that defines them. `DigestPhase.repeats` was populated and read by nothing; it now renders as a `×N` badge.
* **Convergence showed the score but not the saving.** `convergence.repeatsSkipped` is the concrete outcome a DELPHI reader is looking for — "it stopped after 2 of 4" — and only the bare agreement percentage was shown. A converged phase now says how many repeats it skipped.
* **A continuation's round scope was ambiguous.** The bands are correctly scoped to the current round (phase indices restart each round, so mixing them would be wrong), but the headline said only "Round 2". A reader could not tell whether a low turn count meant a quiet round or a view that had lost the earlier ones. It now reads "Round 2 only", with the transcript named as where the rest is.

**Files:** [`phase-rail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/phase-rail.tsx), [`discussion-overview.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/discussion-overview.tsx)

***

## ✨ feat(manager): directional turns, bids, a round switcher and a bounded roster (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

### Why

A sweep of the dashboard against every style, every length and every purpose found four things it did not cover. Three were signal the discussion produces and the view discarded.

**Who addressed whom.** PEER\_REVIEW and DEVIL\_ADVOCATE are *directional* — a critique lands on someone, a challenge is aimed at a position — and `TranscriptEntry.targetAgentId` carries that on every such turn. The dashboard collected it and threw it away, so it could say "Security spoke during Critique" but never "Security critiqued the Architect", which is the content of those styles rather than a detail of them. New `interactions` band, grouped by speaker and led by the heaviest exchange, plus a "nobody addressed" line — in a peer review that names the contribution which drew no scrutiny, which is exactly what a reviewer of the review is looking for.

Rendered as a list rather than a graph on purpose: a force-directed diagram of five nodes is decoration and at twenty is unreadable, while a list answers the two real questions at any size and needs no layout engine. **DELPHI omits this band** — naming who answered whom would undo the anonymity the method rests on, which is the whole reason its later rounds run `ANONYMOUS`.

**Who bid for what.** TASK\_FORCE's contract-net phase (I18) produces `BID` entries that nothing rendered. The task board shows who *holds* a task; this shows who *wanted* it, and the difference matters: a task three members bid on and a task nobody bid on look identical in an assignment list, and the second is the one worth acting on. Grouped by task, because contention is per task. Reuses `readEntryBody` rather than adding a second parser for the same JSON contract.

**Earlier rounds were unreachable.** The bands are necessarily scoped to one round (phase indices restart each round), which left a reader comparing round 1 to round 2 no option but to leave for the transcript. There is now a round selector.

Recovering the earlier boundaries needed care, because only the *current* round's start is persisted. They are read from the `QUESTION` entries the backend writes at every round start — but **validated against the stored boundary** rather than trusted: the recovered list's last entry must equal the round start the backend recorded, and if it does not, the recovery is discarded in favour of the stored boundary alone. That narrows the switcher instead of slicing the view wrongly, and it is not hypothetical — the validation was added because a test with a stray `QUESTION` row split a single-round discussion in two.

**The roster had no bound.** Twenty members meant twenty stance cards, which is the wall of text this view exists to replace. Collapsed past eight.

### Screen sizes, languages, dark mode

Verified in the running Manager rather than asserted: **390 / 768 / 1600px × German (longest strings) and Arabic (RTL) × light and dark**. No horizontal page scroll and no band overflow in any combination; the container queries step the rail 2 → 3 → 5 columns and the roster 1 → 3 as the *pane* widens, not the window. The interaction band's arrow carries `rtl:-scale-x-100`, the idiom three existing components already use — an arrow that keeps pointing right in Arabic reverses the sentence.

### Tests

Frontend 92 across the two overview suites, including the interaction pairs, bid flattening, round selection and clamping, and the boundary-validation fallback. The style-recipe invariant was rewritten: "every band in every recipe" is no longer true now that DELPHI deliberately omits one, so it asserts no repeats plus the core four, with the DELPHI omission as its own named test.

**Files:** [`interaction-map.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/interaction-map.tsx), [`bid-board.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/bid-board.tsx), [`use-discussion-digest.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/hooks/use-discussion-digest.ts), [`style-recipe.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/style-recipe.ts), [`member-roster.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/overview/member-roster.tsx)

***

## ♿ fix(manager): the overview's roster toggle and interaction rows, for screen readers (2026-09-22)

**Repo:** EDDI (`feat/group-discussion-overview`)

Two accessibility gaps flagged in review, both confirmed:

* **The roster's show-all toggle didn't expose its state.** A screen reader user couldn't tell whether the list was expanded. The toggle now carries `aria-expanded` and an `aria-controls` pointing at the list, matching the task board's toggle. The id comes from `useId` so it can't collide.
* **An interaction row lost its direction when read aloud.** The arrow between speaker and target is decorative (`aria-hidden`) and nothing replaced it, so the row was announced as two names side by side, dropping the one fact the band exists to state. A visually hidden connector now reads "Architect addressed Security". It is phrased per language rather than translated word for word: most locales use a verb, but Japanese and Korean put the verb last, so a bare verb there would announce the relationship backwards. They use a possessive label instead ("Architect's addressees: Security").

The connector test checks the text a screen reader actually announces, skipping `aria-hidden` subtrees. A plain `textContent` check would include the hidden arrow and pass whether or not the connector existed. Both tests fail with their fix reverted.

***

## 🔁 fix(backup): agent sync works more than once, can create, and says what it did (2026-09-22)

**Repo:** EDDI (`fix/agent-sync-promotion`)

### What changed and why

Live Agent Sync was tested end to end against two real instances for the first time, and it did not survive the ordinary use it exists for — promoting an agent from staging to production and then keeping it current. Four defects, each in the seam between `UpgradeExecutor` and something it does not own, plus two decisions that made the feature unusable on the network shape most self-hosted deployments have.

**Version resolution always answered 1.** Both [`StructuralMatcher`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/StructuralMatcher.java) and [`UpgradeExecutor`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/UpgradeExecutor.java) asked for the target's current version with `readDescriptor(id, null)`. The descriptor store is historized and its read does `checkNotNull(version)`, so that call *always* threw; the exception was swallowed and a fallback of 1 stood in. So every sync diffed against the target's version 1 — showing the operator pre-sync content as "target" — and then wrote against version 1, which the store refuses once the first sync has moved the resource to version 2. A sync therefore worked exactly once per target agent and then answered `207` with "the store did not accept the update", writing nothing, for ever. Both now use `readCurrentDescriptor`.

**Nothing moved the descriptors.** An upgrade calls the configuration stores in-process, so no JAX-RS filter runs and nothing did what `DocumentDescriptorFilter` does for a `PUT` through the API. The descriptor is what `WorkflowStoreService` reads to deploy an agent — so a synced version was written correctly and then refused to deploy at all, with "Resource not found" for a workflow that was demonstrably in the database — and it is what the Manager lists, so the UI kept showing the pre-sync version. `UpgradeExecutor` now moves the descriptor after each write, and reports a resource whose descriptor could not be moved as a failure rather than as a success.

**A successful sync answered 500.** The endpoint answers `201` with the agent's new-version URI, which `DocumentDescriptorFilter` read as a creation and tried to give a second descriptor — duplicate key, after every write had already landed. Backup endpoints keep their own descriptors and are now skipped by that filter.

**A first promotion was impossible.** With no `targetAgentId` — which the API documents as "create new" and which the Manager sends for any agent it cannot match by name — the request went into `UpgradeExecutor`, whose every path assumes a target. It read the agent `null`, answered `500`, and left the workflow it had already created behind: one orphan per attempt, pointing at resource ids that only exist on the other instance. A sync with no target now fetches the source's own export archive and imports it with `strategy=create`, so a first promotion lands exactly what the same archive would land by hand — schedules, connections, capability registration and rollback included — rather than through a second, thinner create path that would drift from it.

**Snippets are scoped to the agent.** `readSnippets` returned the remote instance's entire snippet store, so promoting one agent proposed copying staging's whole snippet library — unreleased drafts and other teams' snippets — onto production. It now scans the agent's own documents for `{snippets.<name>}`, which is what the export has always done; the one definition now lives in `SnippetReferences` and both sides use it.

**The source address policy is configurable.** HTTPS-only and the private-address refusal were compiled in, so two instances on one internal network — staging and production as neighbouring services, the ordinary self-hosted shape — could not sync at all, whatever the operator wanted, and no setting existed to say otherwise. `eddi.backup.sync.require-https`, `eddi.backup.sync.allow-private-targets` and an exact-origin `eddi.backup.sync.allowed-sources` now express it, all defaulting to today's strict behaviour, each independent of the others. A refused URL is a `400` naming the setting that would allow it, not an unexplained `500`.

The Manager stops reporting a failed sync as a successful one. It read only "the mutation resolved", and `executeSyncBatch` deliberately resolves on `500` too — that status means every mapping failed and the body carries the reasons — so a batch in which nothing was written rendered a green "Sync complete". It now reads the outcome from the results, lists the per-resource reasons, and surfaces the server's own message instead of `res.statusText`.

### How it is guarded now

[`AgentSyncIT`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/integration/AgentSyncIT.java) runs the promotion an operator performs — create, update, update *again*, a no-op, then deploy what was promoted — over real HTTP against the real stores. The existing 768 backup unit tests could not have caught any of this: every one of them mocks the stores, and every mocked target sat at version 1, the one version the broken resolution answered correctly.

**Files:** [`UpgradeExecutor.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/UpgradeExecutor.java), [`StructuralMatcher.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/StructuralMatcher.java), [`RestImportService.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RestImportService.java), [`RemoteApiResourceSource.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/RemoteApiResourceSource.java), [`SourceUrlValidator.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/SourceUrlValidator.java), [`SnippetReferences.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/backup/impl/SnippetReferences.java), [`DocumentDescriptorFilter.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/runtime/rest/interceptors/DocumentDescriptorFilter.java), [`sync-page.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/sync-page.tsx), [`backup.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/api/backup.ts), [`agent-sync-guide.md`](/conversations-and-orchestration/agent-sync-guide.md)

***

## 🐛 fix(llm): `includeFirstAgentMessage` dropped the user's own first turn (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

The flag exists to strip EDDI's opening greeting so the history starts on a user turn — `docs/langchain.md` advised setting it `false` for Anthropic, which used to reject an assistant-first conversation. It was implemented as *remove the first message*, full stop.

For the agent shape it was written for — one with an `ai.labs.output` step producing a greeting at `CONVERSATION_START` — those are the same thing, and it has worked for years. An agent built with **no** output step (a group member that only answers) opens on the **user's** turn. That turn was deleted, the history went out empty, and Anthropic answered:

```
invalid_request_error: messages: Field required
```

A flag whose only purpose is to satisfy Anthropic's first-message rule was, in that configuration, what made Anthropic reject the request. Two things hid it: Ollama accepts an empty message list, so a local smoke test passes on a config that cannot work against the real provider; and the documented advice reads as universal while only covering the standard agent shape.

The premise has also expired. The Anthropic Messages API reference no longer documents a "first message must be the user's" rule, and an assistant-first history is accepted.

### What changed

* [`ConversationLogGenerator.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/ConversationLogGenerator.java) drops the first message only when its role is `assistant`. Behaviour is unchanged for every agent that has a greeting — for those the first message genuinely is the agent's — so the fix is strictly additive.
* [`ConversationHistoryBuilder.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/modules/llm/impl/ConversationHistoryBuilder.java) carried a second copy of the same unconditional `removeFirst()` in `generateMessagesFromOutputs`. Both callers pass `skipSteps > 0` today, so that branch is currently unreachable and has no behavioural test of its own; it is written correctly rather than left as a trap for the next caller, and the comment says so.
* [`docs/langchain.md`](/agent-configuration/langchain.md): the blanket "set it `false` for Anthropic" is gone from the parameter table, the Anthropic example and the troubleshooting section, replaced by an entry for the `messages: Field required` failure itself.

**Tests:** four cases in `ConversationLogGeneratorTest` and four in `ConversationHistoryBuilderTest` (including the token-aware path). The pre-existing `excludeFirst` case asserted the bug — it built a **user**-first history and asserted the message was removed — and was rewritten. Mutation-checked: restoring the unconditional `removeFirst()` fails five of them.

### And now deprecated

The flag's only documented reason to exist has expired, so it is marked **deprecated**: `LlmTask` logs a WARN naming the task the first time each configured task uses it, the parameter table says not to use it in new configs, and `docs/langchain.md` gains a *Deprecated parameters* section explaining what to do instead.

**Deprecated rather than removed**, deliberately. Agent behaviour lives in JSON stored in MongoDB and imported from ZIPs — per `AGENTS.md`, the one backward-compatibility boundary this codebase has. Silently ignoring a parameter an author set on purpose would be *worse* than honouring it: an agent that genuinely wants its greeting withheld would start sending it, with no diagnostic. The flag keeps working exactly as before.

Once per task, not once per turn: an LLM task runs on every message of every conversation, and a per-turn WARN is a flood operators learn to filter out — which is the same as not warning at all.

***

## 🔒 fix(security): a missing `role-claim-path` 403'd every admin, silently (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

`quarkus.oidc.roles.role-claim-path=realm_access/roles` is one line in `application.properties`, and the released 6.4.0 image shipped without it. quarkus-oidc then falls back to its default `groups` claim — which is also `eddi.workspaces.groups-claim`'s default — so any account belonging to a Keycloak group had its EDDI roles **replaced** by its group paths, and every `@RolesAllowed` endpoint answered **403 with an empty body and not one log line**. The shipped realm puts the seeded `eddi` administrator in `/engineering`; accounts in no group fell through to `realm_access` and worked. It presents as "this one account is broken", not as a configuration gap, which is what made it cost hours to find.

### What changed

* [`AuthStartupGuard.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/security/AuthStartupGuard.java) gains `rolesClaimDiagnostic()`, logged at ERROR on startup whenever OIDC is enabled and the roles claim path is unset, blank, or equal to the workspaces groups claim. It names `QUARKUS_OIDC_ROLES_ROLE_CLAIM_PATH` and the 403 symptom. Pure and package-private, so the branch is assertable without a container. It runs before the launch-mode branch: the auth E2E tier runs in `TEST` and the diagnostic matters there too.
* [`OidcRolesClaimConfigTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/configs/OidcRolesClaimConfigTest.java) pins the property in the source tree — present, non-blank, `realm_access/roles`, and not equal to `eddi.workspaces.groups-claim`. Value-pinned rather than presence-only: an edit to `groups` would reintroduce exactly the failure and still pass a presence check.

The two halves are deliberate: the test stops it being dropped from the source, so the runtime ERROR stays a warning about an operator override rather than about us.

***

## 🐛 fix(ui): `/manage/` answered 200 with an empty body (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

`GET /manage` served the app; `GET /manage/` served `content-length: 0`. The trailing slash reaches `{path:.*}` as an **empty** path, `normalizeSlashPath` drops empty segments, and the lookup became `getResourceAsStream("META-INF/resources/")` — a directory entry, which a classloader answers with an open, empty stream rather than `null`. The missing-asset check was satisfied, the `manage.html` fallback never ran, and the browser got nothing.

### What changed

[`RestManagerResource.java`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/ui/RestManagerResource.java): a path that normalizes to nothing resolves straight to the SPA shell, and the two fallback sites are one `serveManagerIndex()` helper.

**Tests:** `RestManagerResourceTest` asserts the **lookup sequence** rather than the body — the unit run resolves off an exploded `target/classes`, where directory names behave differently from jar entries, so what must hold on every layout is that the resource base is never asked for at all. Covers `""`, `"/"`, `"//"`, `"./"` and `"/./"`. Mutation-checked.

***

## ✨ feat(groups): a rejected decision is `REJECTED`, not `FAILED` (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

Rejecting a group discussion's recommendation at a HITL gate set the conversation to `FAILED`, and the Manager rendered a red "Failed" badge on it. Semantically defensible — the run did not complete — but it reads as a system error rather than as a recorded human decision, and in a product whose selling point is the human in the loop that conflation is the wrong way round: the run did exactly what it was asked.

### What changed

`GroupConversationState.REJECTED`, set by [`GroupHitlCoordinator`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/groups/GroupHitlCoordinator.java) on a `REJECTED` verdict. It permits **exactly** what `FAILED` permitted — the label is the only thing that changed:

* terminal in `GroupLifecycleOps.isTerminalState` and in the coordinator's `persistedTerminalOverride` and cancel guards;
* closeable — `closeGroupConversation`'s CAS chain and its refusal message now come from one `CLOSEABLE_STATES` list, so a new terminal state cannot be added to the chain and left out of the error, which is what three hand-written `if` blocks beside a hard-coded sentence invited;
* `availableActions` answers `["close"]`, as for `FAILED`;
* ephemeral agents are reclaimed immediately, as for `FAILED`.

Documents written before this carry `FAILED` for a rejection and are **left alone**: nothing in them distinguishes the two, so a migration could only guess.

**Rolling downgrade is one-way.** Jackson serializes the enum by name, so a document written by this version and read by an older EDDI fails `valueOf`. Upgrading a cluster is safe; rolling back a node that has already served a rejection is not, until those documents age out. The same goes for a Manager older than its backend: `GroupConversationState` is a closed union there too.

The cadence reconciler in `TeamCadenceService` was the one place the new state had to be routed by hand — it switches on the enum with a `default` arm rather than exhaustively, so `REJECTED` fell through to "still running" and wedged the standing team's claim for `eddi.groups.cadence.claim-ttl` (default 24 h). Caught in review; it is the only such switch in `src/main`, and it now carries a comment saying so.

### Also

[`RestGroupConversation.setDecidedByFromIdentity`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestGroupConversation.java) writes a blank principal name as `null` rather than `""`. The server has always overwritten the client's claimed `decidedBy` from the authenticated principal, which is correct for an audit ledger — but with `eddi.security.allow-unauthenticated=true` there is no principal, and the ledger recorded `"decidedBy": ""`. The audit writer already renders a null decider as `"unknown"`, so the unauthenticated case now lands on that same honest value.

***

## ✨ feat(groups): say at save time when debate roles turn a synthesis into a verdict (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

Giving members structural roles switches the SYNTHESIS phase onto the debate-judgment prompt: the moderator is asked for `{"winner": …, "scores": …}` and its own synthesis instruction is not used. A grant board whose members carried `role: PRO` and `role: CON` had its chair return a scoring verdict instead of the recommendation its system prompt specified. Correct for a debate-scoring exercise, wrong for anything else — and discoverable only by running it, because nothing in the configuration says so.

### What changed

[`AgentGroupStore.debateVerdictSynthesisPhaseNames`](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java) reports the phases that will take the verdict path, logged at **INFO** on create and update. INFO rather than WARN, deliberately: for a real debate this is the intended behaviour and the note is its documentation, not a complaint.

It mirrors `GroupContextBuilder.isDebateJudgment` with the two substitutions a config-time check has to make — an `ARGUE`/`REBUTTAL` *phase* before the synthesis in place of argument entries on the transcript, and only `participants: "MODERATOR"` phases, which are the only ones whose speaker is resolvable from configuration. A synthesis open to other participants is left unreported rather than guessed at. Preset-expanded, or it would be inert for exactly the style it matters most for. `moderatorlessPhaseNames` now shares the same `resolvedPhases` helper so the two cannot drift.

**Tests:** eleven cases in `AgentGroupStoreTest`, one per condition — two sides, a chair that is itself a debater, a moderator-less roster, an explicit `inputTemplate` (the documented opt-out), arguments after the synthesis, and a ROUND\_TABLE with debate roles.

***

## 🐛 fix(manager): "Show more" on a group's question could not be undone (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

The question header is pinned above a group discussion's transcript and clamps a long brief to four lines. Expanding removed the clamp and nothing replaced it: the header is `shrink-0` inside an `h-full` flex column, so a long brief grew it past the bottom of an `overflow-hidden` pane and took the transcript, the composer **and its own "Show less" button** with it. Nothing could be scrolled back to reach the toggle — the scroll container is the transcript below, not the header — so the only way out was to reload the page.

### What changed

[`discussion-transcript.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/components/groups/discussion-transcript.tsx): an expanded question is height-bounded (`max-h-[30vh]`) and scrolls in place, so the header cannot outgrow the pane and the toggle stays where it was.

**Tests:** four cases in `discussion-transcript.test.tsx` — clamped by default, bounded and self-scrolling once expanded, collapsible again, and untouched for a short question.

***

## 🐛 fix(manager): "New Discussion" was a no-op on a group with history (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

The handler cleared the selection; the auto-select effect, which lists `selectedConvId` in its own dependencies, immediately put the newest conversation back. Worse than cosmetic: attachments are accepted only on a **new** discussion (the backend rejects a continuation carrying any), so the upload control is not rendered while a conversation is selected — **a group that had ever held one discussion could never accept a file again**, with no error to explain it.

### What changed

[`group-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/group-detail.tsx) records an explicit clear in a ref the auto-select effect honours. It is set wherever the page deliberately empties the selection (New Discussion, starting a stream, switching to a resumed stream) and cleared when a conversation is deliberately selected again — so a plain load still auto-selects. The Workforce board already avoided this, with a one-shot restore ref.

**Tests:** five cases in `group-detail-selection.test.tsx`, the load-bearing one being that the attachment control comes back. Mutation-checked: removing the ref check fails four of them. The MSW group-conversation fixture also gained the `availableActions` field the backend always serializes — without it the Manager read `[]` and disabled the composer, so a fixture-backed test could not tell "continue this discussion" from "this discussion is over".

***

## ✨ feat(manager): the Workforce advisor thread can start over (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

Every other chat surface in the Manager offers one — `chat-panel` and `chat-drawer` ("New Conversation"), `operator-chat`, and the Workforce board ("New"). The 1:1 advisor thread did not, and its conversation is pinned in `localStorage` by (board, member): the thread it resumes on every visit is the one it started the first time. Escaping a derailed thread meant clearing site data.

### What changed

[`workforce-thread.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/workforce/workforce-thread.tsx) gains a "New conversation" control beside the details toggle. The "start fresh" half of the init effect is now a callback both paths share, so the button and a first visit take the same route. Registering the thread repoints the stored (board, member) entry at the new conversation; the old one is left on the server, which is what "New Conversation" means everywhere else in the Manager.

**Tests:** `workforce-thread-new-conversation.test.tsx` — the control exists, it starts a conversation and repoints the store, it clears the transcript, the composer stays usable, and a failed start leaves the thread on the old working conversation rather than a dead id.

***

## 🐛 fix(manager): the log SSE stream no longer opens on every page (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

`main.tsx` imported `session-log-store` for its side effect and that module connected on load, so **every Manager tab held an open `/administration/logs/stream` SSE connection on every page**, for the whole lifetime of the tab, whether or not anyone ever opened the Logs page.

EDDI serves HTTP/1.1, where Chrome allows **six concurrent connections per origin for the entire profile**, and a live group discussion opens another. Measured with the demo idle, Chrome sat at exactly six, saturated. The symptom is pages hanging on skeleton loaders forever, intermittently, while the server answers every request in 0.21 s with zero variance and no errors — it looks exactly like a dead backend and is not.

Verified two ways: a *fresh* tab opened directly on `/manage/audit`, with no prior navigation, issued `GET /administration/logs/stream`; and after 48 s on that page `performance.getEntriesByType('resource')` carried no completed entry for it at all, while an ordinary request on the same page reported `responseEnd: 547`.

### What changed

The stream is lazy and reference-counted. `connect()` returns an idempotent release (safe as a React 19 double-invoked effect cleanup), a second consumer reuses the open socket, and it closes when the last one leaves. `useLogStream` holds it only while mounted and unfiltered; the filtered path already opened and closed its own, as does the debugger's live log viewer. The bare import is gone from `main.tsx`.

What was lost is small: the buffer no longer accumulates from app boot. It never needed to — the store seeds from `getRecentLogs` on open, so arriving at the Logs page still shows history.

**Tests:** seven lifecycle cases in `session-log-store.test.ts` (including that importing the module opens nothing, asserted through a new `isStreamOpen()`), three ownership cases in `use-logs.test.tsx`, and a source-level guard that `main.tsx` never imports the module again — the runtime tests cannot see that, and `main.tsx` is not importable from a test.

***

## ✨ feat(manager): a rejected discussion reads as a decision (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### What changed

The UI half of the backend's new `REJECTED` state. The transcript badge and the discussion list carry a neutral "Rejected" rather than a red "Failed"; the composer says the recommendation was rejected rather than "this discussion has ended"; the Workforce analytics, history and session views label it too — TypeScript's exhaustive `Record<GroupConversationState, …>` maps found every one of those reads, which is why the state was added to the union first.

The SSE hook now honours the `state` the backend puts on `group_complete` instead of hardcoding `COMPLETED`. `group_complete` is the terminal notification for every outcome, and a rejection ends the run as `REJECTED` — rendering it as "Completed" for the seconds before the persisted conversation loads says the opposite of what happened.

The page's settle effect — which switches the transcript from the live stream to the persisted conversation and refreshes the sidebar — listed its states by hand, so `REJECTED` matched nothing and a live rejection stranded the page: no conversation selected, the composer inviting a *new* discussion, the Close action unreachable, and the sidebar saying "Awaiting Approval" indefinitely (the conversation-list poll only runs while a discussion is IN\_PROGRESS/SYNTHESIZING). Caught in review. That list is now a named constant with the reasoning attached, including why FAILED and CANCELLED are deliberately not in it.

`GROUP_CONVERSATION_STATES` is now a runtime array in `lib/api/groups.ts` with the union derived from it, because several render sites key translations off the state name with a template literal — ``t(`groups.state.${state}`)`` — which `npm run i18n:check` cannot see. `groups.state.REJECTED` was missing from all eleven locales with every gate green; the transcript export would have written the raw token into a downloaded file.

**Tests:** `discussion-rejected-state.test.tsx` — the label, the absence of the destructive badge, the declined synthesis staying visible, and the live-stream label; a rejection driven through the approve path in `group-detail-selection.test.tsx`; and a locale sweep over `GROUP_CONVERSATION_STATES` in `i18n-quality.test.ts`.

***

## ✨ feat(manager): the editor says when a debate synthesis answers with a verdict (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### What changed

`debateVerdictSynthesisPhaseNames` in [`lib/group-config.ts`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/lib/group-config.ts) mirrors the backend helper (and through it `GroupContextBuilder.isDebateJudgment`), and the group config panel renders an informational note naming the phases. Same mirror-and-surface shape as the existing moderator-less and role-coverage warnings, which exist because the backend only ever wrote those to its own log.

Styled as a note rather than a warning: for a real debate the verdict path is what was asked for. The point is that nothing else in the configuration said so.

**Tests:** nine cases on the helper in `group-config.test.ts`, one per condition, plus three on the panel.

***

## 🐛 fix(manager): a plain Save no longer claims a change is live (2026-09-22)

**Repo:** EDDI (`fix/pilot-demo-findings`)

### Why

The config editor offers two actions: `handleSave`, which cascades resource → workflow → agent, and `handleSaveAndDeploy`, which cascades and then deploys, polling for up to 30 s. The first reported "Saved successfully" with nothing to say the running agent was still serving the previous version.

Measured on an eligibility gate (no model, sub-second, so the effect is unambiguous) with the ceiling lowered from 150,000 to 50,000 and a case of 85,000: cascade **+ deploy** gave `gate_fail_over_cap`, cascade **alone** gave `gate_pass` — no change at all. Resource, workflow and agent versions had advanced to v4/v5 while the deployed version stayed at v3.

### What changed

[`resource-detail.tsx`](https://github.com/labsai/EDDI/tree/main/ui/manager/src/pages/resource-detail.tsx): the cascade path's toast is now "Saved — not yet live", explains that the running agent still serves the deployed version, and carries a **Deploy** action that deploys the agent version the cascade just produced (not the stale one the URL carried). A failed deploy surfaces its error rather than doing nothing.

**Tests:** `resource-detail-save-not-live.test.tsx` — the wording, the explanation, the version the action deploys, and the failure path.

***

## 🛠️ chore(skills): a `ship-pr` skill that carries a PR from push to every review comment resolved (2026-09-21)

**Repo:** EDDI (`chore/ship-pr-skill`)

### Why

Shipping a PR here has a long tail of repo-specific traps, and every session rediscovers them. The two that cost the most: the `CodeRabbit` check reads **`pass` while the description column says `Review rate limited`** — so "green checks, zero unresolved threads" can mean *nothing was reviewed*; and a targeted `-Dtest='A,B'` **silently drops a missing class and exits 0**, because surefire's `failIfNoSpecifiedTests` only trips when zero tests ran.

### What changed

* `.claude/skills/ship-pr/SKILL.md` — the workflow: pre-push gates, a PR-description shape built from the diff, what actually gates a merge (branch protection requires only `Build & Test` and `CodeQL Analysis`; everything else is advisory), and a review loop that covers all three places feedback lands.
* `.claude/skills/ship-pr/pr-threads.sh` — audits review threads via GraphQL, prints the node id the reply/resolve mutations take, and drops CodeRabbit's collapsed `<details>` block so the preview shows the finding rather than `🏁 Script executed:`.
* `.gitignore` — un-ignore `.claude/skills/` only; `settings.local.json`, `workflows/` and `worktrees/` stay ignored. Without this the skill files never land.

### Decisions

* **Tracked, not personal.** The content is repo-specific team knowledge, mirroring the precedent in `ui/manager/.claude/skills/`.
* **Nitpicks are handled separately from threads.** CodeRabbit puts nitpicks, duplicates and outside-diff-range findings in the *review body*, where they have no thread and cannot be resolved — and some are 🟠 Major. They get one disposition comment instead.
* **Only bot threads get resolved.** A human closes their own, whether the finding was fixed or argued down.

Sibling skills for `gnowbe-frontend` and `gnowbe-api-2` ship in those repos.

***

## RAG docs: document the workflow step that actually binds a knowledge base (2026-09-21)

**Repo:** EDDI · **Branch:** `docs/rag-workflow-step-binding`

A user reported that `product-docs` retrieval "does not work" on their deployment and concluded the `RagContextProvider` wiring was missing from the build. The wiring is present and has been since Phase 8c — `LlmTask` calls `retrieveContext` and appends `## Relevant Context:`. What was missing was the third side of the binding, which the docs never showed.

### The failure the docs were producing

A knowledge base reaches an agent through three configurations, not two:

1. the `RagConfiguration` at `/ragstore/rags/{id}`,
2. an `eddi://ai.labs.rag` **step in the agent's workflow**,
3. `knowledgeBases` / `enableWorkflowRag` on the LLM task.

`RagContextProvider` matches `knowledgeBases[].name` against configs discovered from the **workflow document** (`WorkflowTraversal.discoverConfigs(memory, "eddi://ai.labs.rag", …)`). With no such step `ragSteps.isEmpty()` is true and retrieval returns at a `LOGGER.debug` line, before the trace entry, before the store build, before any INFO log. Every symptom in the report falls out of that one early return: no `## Relevant Context:` block, no `rag:trace:*`/`rag:context:*`, and no `RagContextProvider` or `EmbeddingStoreFactory` lines while other tool providers logged on every turn.

A second cause presents identically and the troubleshooting section now says so: when the workflow *does* bind a step but no `knowledgeBases[].name` matches its KB name, every step is `continue`d past, `traceEntries` stays empty so no trace is stored, and `allResults.isEmpty()` returns null just the same. Only the `DEBUG` line distinguishes them — it is logged for the missing-step case alone.

A third cause sits even earlier and was missed in the first pass at this section: `retrieveContext` checks the *task* before it ever looks at the workflow — `if (!hasExplicitRefs && !useWorkflowDiscovery) return null;` at the top of the method. A task with an empty `knowledgeBases` and no `enableWorkflowRag: true` returns here, before `WorkflowTraversal.discoverConfigs` runs at all, so it produces no discovery, no trace, no store build, no INFO log — and, unlike the missing-step cause, no `DEBUG` line either, because that line lives inside the `ragSteps.isEmpty()` branch this return never reaches. The troubleshooting table now leads with checking the task's own RAG settings before touching the workflow.

`rag.md` mentioned the requirement only in a subordinate clause ("Each reference names a KB from the workflow") and in a Status bullet at the bottom of the page. Its setup path showed the KB config and the LLM task and nothing else — so a reader who followed it end to end built exactly the broken two-sided configuration that was reported. That is a documentation defect, not a user error.

### `docs/rag.md`

* **Configuration** now opens with the three-sided binding as a table, and names configuring 1 and 3 without 2 as the most common failure — explicitly including that it produces no context, no trace, no error and no log above `DEBUG`.
* **New `### 2. Workflow Step`** section with the workflow JSON, the `{ragId}.rag.json` ZIP name, why `RagTask.execute()` is a no-op while `configure()` resolves the KB, and the `GET /extensionstore/extensions` check for builds where `ai.labs.rag` is not registered.
* Existing sections renumbered to `1.`/`3.` to match. No inbound anchor links existed.
* **Fixed a dangling sentence**: "Three of them choose what is retrieved:" was followed by nothing. The three are now named.
* Options 1 and 2 state that the name matches the KB's `name` (not its id) and that an unmatched name is skipped silently; Option 3 states that `httpCallRag` calls an *external* API and cannot query an EDDI knowledge base.
* **REST API**: states outright that no `/query`, `/search` or `/retrieve` endpoint exists and that those return `404`. Users were trying `httpCallRag` against their own KB as a workaround and hitting 404s with nothing telling them the endpoint was never meant to exist.
* **Document Ingestion**: warns off the `kbId` query parameter, found while reviewing this change. `RagContextProvider` keys the embedding store on `ragConfig.getName()` and cannot be pointed elsewhere, but `RestRagIngestion` lets the caller override that key (`effectiveKbId`, favouring the `kbId` param over the name). Any `kbId` other than the KB's exact `name` therefore ingests into a store nothing reads — `202`, status `completed`, documents genuinely embedded and stored, retrieval empty for ever. Ingestion *sources* are unaffected: `IngestionPipeline` keys on `knowledgeBase.getName()`, and `IngestionRetrievalRoundTripTest` pins that round trip after an earlier draft shipped exactly this divergence on the source path.
* **Vector Stores**: `in-memory` is no longer described only as "ephemeral, for dev/test only". The cached object *is* the data, and `EmbeddingStoreFactory` holds it in a Caffeine cache bounded at 50 stores with a 30-minute `expireAfterAccess` and a full invalidation on any secret or global-variable change — so an in-memory KB empties itself after 30 idle minutes, on restart, and on credential rotation, then returns no context rather than an error.
* **New `## Troubleshooting`** section: an ordered seven-step check for the silent-no-context case (task-level RAG settings checked first, ahead of the workflow-binding checks), and the `quarkus.log.category` line that makes the missing-step early return visible — called out as not covering the task-level or unmatched-name causes, which log nothing at any level.
* **Status**: the workflow-step bullet said "Options 1 and 2 below" while they are above it.

### `docs/langchain.md`

The task-parameter reference documented `maxRagContextChars` and nothing else about RAG — the one knob that merely *bounds* a feature the page never introduced. Added `knowledgeBases`, `enableWorkflowRag`, `ragDefaults` and `httpCallRag` under a **Retrieval (RAG)** group whose header carries the workflow-step requirement and links to `rag.md`.

### Not changed

The engine. No defect was found on the retrieval path itself. Three gaps are recorded here rather than fixed, each arguably worth its own issue:

1. **`kbId` on `/ingest` can write where nothing reads** (above). The parameter has no correct non-default value, because retrieval cannot be pointed at a custom key — so the fix is probably to reject a `kbId` that does not equal the KB's `name`, or to drop the parameter, rather than to document it. Documented here because a doc change cannot make a `202` mean something else.
2. **No retrieval REST endpoint** for a knowledge base, so there is no supported way to test that ingestion worked without running a conversation — which is what sent the reporting user to `httpCallRag` and a wall of 404s.
3. **`ai.labs.rag` registration is in no release tag.** It is on `main`; the newest tag is `6.4.0`. Deployments on `6.4.0` or earlier cannot wire up vector RAG at all, whatever the docs now say.

***

## 📊 feat(llm): per-call LLM telemetry on every provider and both execution paths (2026-09-21)

**Repo:** EDDI (`feat/llm-observability`)

An agent that names a single model — almost every agent — produced **no LLM telemetry at all**: no latency timer, no token counter, no error counter, and no span below `eddi.pipeline.task`. A turn that spent eleven seconds waiting on a provider was indistinguishable from one that spent eleven seconds in EDDI's own code. The `eddi.llm.cascade.*` meters that did exist live in `CascadingModelExecutor`, which `LlmTask` reaches solely under `if (cascadeActive)`.

### Why there was nowhere to put it

`ChatModel.chat(ChatRequest)` delegates to `chat(ChatRequest, ChatRequestOptions)`, and *that* is where the interface merges `defaultRequestParameters()` and fires `onRequest`/`onResponse`/`onError` from `listeners()` around `doChat`. It is the only per-call hook langchain4j offers, and it covers every provider because the dispatch lives on the interface rather than in each binding.

`ObservableChatModel` overrode `chat(ChatRequest)`, which bypassed that default entirely — so the decorator had no listener dispatch of its own. And `wrapIfNeeded` returned the **bare** model unless `timeout`/`logRequests`/`logResponses` was configured, which is the default, so most deployments had no decorator at all.

### What changed

* **`ObservableChatModel`** now overrides `doChat` and delegates `defaultRequestParameters()`, `provider()` and `supportedCapabilities()`, so the inherited `chat` behaves as the provider's own would and fires listeners.
* **Both decorators always wrap.** Returning the bare model is what left the default path with nowhere to attach a listener.
* **`LlmTelemetryListener`** (new) emits `eddi.llm.request.duration`, `eddi.llm.tokens` and `eddi.llm.request.errors`, plus a `gen_ai.client.inference` span.

### Three design decisions that are not the obvious ones

* **`doChat` forwards to `delegate.chat(...)`, not `delegate.doChat(...)`.** A `ChatModel` may implement either, and `JlamaChatModel` implements `chat` — forwarding to `doChat` would throw `"Not implemented"` on every Jlama turn, i.e. on the provider the previous PR just fixed.
* **`listeners()` returns EDDI's listener only, never the delegate's.** Because the delegate re-enters its own `chat`, it dispatches its own listeners; combining the lists would fire every provider-registered listener twice. Mutation-tested: the combining version produces `[request, request, error, error]`.
* **`ObservableStreamingChatModel` moved from overriding both `chat` overloads to overriding `doChat`.** This was a live defect in an earlier revision of this branch: the two-argument `chat(request, options, handler)` is where the streaming interface reads `listeners()`, so overriding it meant EDDI's listener never fired on a streaming turn — silently, while the javadoc claimed otherwise. Nothing caught it because every streaming test passed `null` for the listener and asserted only on tokens.

### Also fixed

The timeout path boxed the provider's exception in a bare `RuntimeException`. Harmless before; now that the error tag and `error.type` are derived from the exception class, it made every failure on a timeout-configured agent read as `RuntimeException`. The cause is rethrown directly, and the timeout itself gets a named `ChatTimeoutException`.

`LlmTelemetryListener.guard` logs at WARN, not DEBUG. langchain4j already contains a throwing listener (`ChatModelListenerUtils` wraps each in `try/catch` and logs WARN), so catching at DEBUG would only have downgraded upstream's message — meaning a misconfigured `MeterRegistry` would lose every LLM meter with nothing in the log at default levels.

### Semantic conventions

The span uses the **current** names — `gen_ai.provider.name` (renamed from `gen_ai.system` in semconv v1.37.0) and `gen_ai.usage.input_tokens`/`output_tokens`. That namespace is still Development-status and moved to its own repository in v1.42.0 precisely so it can keep changing, so the span also carries `eddi.semconv.schema_version` recording the revision the names came from. Micrometer meter names stay EDDI-owned (`eddi.llm.*`) so an upstream rename cannot break a dashboard.

### Tests

`ObservableChatModelDecoratorTest` (9, new) uses a delegate shaped like a real binding rather than a mock stubbing `chat`, and pins the properties the old tests could not see: a `chat`-only delegate still works, each listener fires exactly once, the decorator carries only EDDI's listener, and the legacy `chat(List<ChatMessage>)` route — the default non-JSON path through `LegacyChatExecutor` — is observed too. `LlmTelemetryListenerTest` (8, new) grades the meters, including that absent token usage records *nothing* rather than a zero. Two new streaming tests assert a listener actually fires; mutation-checking the old override shape turns them red with `[]` against `[request, response]`.

`docs/metrics.md` and the full-metrics Grafana dashboard gained the three meters — `MetricsDashboardCoverageTest` enforces both.

### Follow-up: the p95 panel had no series to query

Raised in review of [#809](https://github.com/labsai/EDDI/pull/809). A Micrometer timer publishes `_count`, `_sum` and `_max` and no buckets at all, so `eddi_llm_request_duration_seconds_bucket` — the series panel `id: 169` runs `histogram_quantile(0.95, ...)` over — was never exported. The panel would have rendered empty forever, which on a latency chart reads as "no LLM traffic" rather than "this metric does not exist".

`eddi.llm.request.duration` is now registered with `publishPercentileHistogram()`, the same way `eddi.pipeline.task.duration` in `LifecycleManager` already is — which is also why the pipeline p95 panels next to it do work. The cost is one series per bucket per `provider`/`model`/`outcome`, and the tag set is bounded the same way: providers are a fixed list, `outcome` is success or error, and model names come from configuration rather than from user input. If a deployment does find that too much, the dashboard-side alternative is to plot `_sum / _count` as a mean and drop the line; that is recorded at the call site.

`LlmTelemetryListenerTest.durationTimerPublishesHistogramBuckets` asserts against a real `PrometheusMeterRegistry.scrape()` rather than a `takeSnapshot().histogramCounts()`, because the scrape text is literally what the dashboard queries — a snapshot assertion would pass on a registry that never exports the buckets. Mutation-checked: removing `publishPercentileHistogram()` turns exactly that test red.

***

## 📄 feat(rag): ingest uploaded PDF, Word, Excel, PowerPoint and text files (2026-09-21)

**Repo:** EDDI (`feat/rag-file-upload`)

### What it adds

A second kind of ingestion source. `type: "upload"` holds files EDDI stores on the knowledge base's behalf; running the source extracts their text and embeds it, through the same pipeline, state store and reconciliation a crawl uses. In the Manager, a source of that type shows a drag-and-drop zone instead of the crawl settings, with per-file progress and the list of what the source holds.

Formats: PDF (PDFBox), Word, Excel and PowerPoint (`.docx` / `.xlsx` / `.pptx`), plain text, Markdown, JSON, XML, YAML, CSV, TSV and HTML. Excel becomes one Markdown table per sheet, PowerPoint one section per slide, Word keeps its headings — tabular and sectioned content survives chunking only if it keeps the header or heading a retrieved passage would otherwise have lost.

### Design decisions

**The files are kept, not just their embeddings.** That is what makes this a *source* rather than a one-way import: re-running after a model or chunk-size change re-ingests from what is stored, a purge stays recoverable, and deleting a file removes its vectors through the ordinary reconciliation. Embedding on upload and keeping nothing would make each of those "ask the operator to upload two hundred files again".

**No Apache POI.** It reads these formats and much more, at seven extra jars and \~14.5 MB, built on reflection and with a long history of parser CVEs — measured against the constraint that EDDI's dependencies stay small. For text out of a handful of known parts, the JDK's own `java.util.zip` plus StAX is the smaller surface and the one whose limits can be stated exactly. PDFBox was already a dependency (3.0.8, current).

**Every bound is explicit, because the file is not the operator's data.** 100,000 characters per document (`settings.maxContentLength`, shared with the crawl), 500 parts, 5,000 rows × 64 columns per sheet, 64 MB decompressed per archive — counted across every entry the reader walks over, since moving to the next ZIP entry inflates the rest of the current one and an entry nobody wants is otherwise the cheapest place to hide a bomb. DTDs and external entities off (billion-laughs); an archive naming the same part twice refused outright, since two entries under one name let two readers disagree about the contents.

**Content decides the format, not the name.** `.docx`, `.xlsx` and `.pptx` are all ZIP archives, so neither the file name nor the browser's MIME type distinguishes them — a spreadsheet saved under a `.docx` name would have gone to the Word extractor and been refused as corrupt. The extension is consulted only for text formats, which carry no signature. A legacy `.doc`/`.xls`/`.ppt` is named as such, with the fix, rather than reported as unreadable.

**A file's identity is its name.** Re-uploading `handbook.pdf` replaces it — blob, ingestion-state row and vectors all key on an id derived from the name — so the corrected version supersedes the old one everywhere at once. A generated id would leave last quarter's handbook retrievable beside this quarter's with nothing to say which is current.

**Change detection uses the file's bytes, not its extracted text.** An unchanged 20 MB manual costs one metadata query per run rather than a download and a full parse, and improving an extractor does not silently re-embed an entire knowledge base.

**One HTTP request per file, not one per batch.** A 200 MB batch that fails three quarters of the way through would otherwise lose what had already arrived with nothing to say which files those were. Each file gets its own progress bar (`XMLHttpRequest`, since `fetch` cannot report upload progress) and its own error, and the server's sentence — "This PDF is encrypted" — is what the operator sees.

**Deleting a file removes its vectors immediately**, not at the next run. A source with no cron has no next run, so deferring it would mean an operator is told a document is gone while agents keep answering from it. Where the vector store cannot delete by metadata, the response says so rather than reporting a clean success.

**Removing an upload source takes its content with it.** Dropping it from `sources[]`, changing its `type`, or deleting the knowledge base removes the files *and* the vectors they produced. The first draft deleted only the files, which left every chunk retrievable and unreachable: no endpoint lists them, because the source they belong to is gone.

**Deleting a file takes the source's run claim.** Checking for a run first is not enough — one that starts between the check and the delete re-embeds the file and clears the tombstone the delete wrote.

### Bugs found and fixed while testing

**An upload source could never delete its last documents.** `reconcileDeletions` refused to conclude anything when a run produced no usable document and learned nothing definitive — a guard written for crawls, where "nothing came back" usually means the site was unreachable. An upload source that lists an empty store has learned something definitive: the operator deleted the files. The guard moved into the crawl branch (`learnedSomething`), where it belongs; the pre-existing crawl tests still pin it.

**A reordered PowerPoint deck came back in creation order.** `r:id` and `id` share a local name on `<p:sldId>`, so reading the attribute by local name returned the slide's own number instead of the relationship — the index resolved to nothing and the fallback (part numbering) ran. Part numbers survive a reorder, so the deck read correctly right up until somebody moved a slide.

**An encrypted PDF was reported as corrupt.** PDFBox refuses one of those with `InvalidPasswordException` rather than by opening it and answering `isEncrypted()`, so the only branch that mentioned a password never ran, and the operator was told their working file was broken.

**A 25 MB file — the default limit — could not be uploaded at all.** `quarkus.http.limits.max-body-size` was 25M, so the request was refused with a bare 413 before the code that knows what the limit is could say anything. Raised to 60M, with the configurable ceiling held at 50 MB and both ends commented.

### Files

**Backend — extraction** (`src/main/java/ai/labs/eddi/modules/ingestion/extract/`): `DocumentExtractors` (registry + content-based type resolution), `DocumentTextExtractor`, `ExtractionLimits`, `PdfTextExtractor`, `WordTextExtractor`, `ExcelTextExtractor`, `PowerPointTextExtractor`, `PlainTextExtractor`, `CsvTextExtractor`, `HtmlDocumentExtractor`, `OpenXmlPackage` (bounded ZIP+StAX), `MarkdownTable`, `Extraction`, `UnreadableDocumentException`.

**Backend — storage** (`modules/ingestion/files/`): `IIngestedFileStore`, `IngestedFileIds`, `IngestedFileService`; `MongoIngestedFileStore` (GridFS), `PostgresIngestedFileStore` (`bytea`, upsert on `(source_key, file_id)`), wired in `DataStoreProducers`.

**Backend — pipeline and API**: `IngestionPipeline` (upload branch, `SourceRun`, `forgetDocument`, `learnedSomething`), `IngestionSource` (`TYPE_UPLOAD`, `UploadSource`), `RagSourceIngestionService` (files deleted with their source), `IRestRagIngestion` / `RestRagIngestion` (three endpoints), `ContentHashes.sha256Bytes`.

**Manager**: `ingestion-files-panel.tsx` (drop zone, per-file progress, file list, delete), `ingestion-sources-panel.tsx` (Website/Files chooser, conditional fields), `lib/api/ingestion-sources.ts`, `hooks/use-ingestion-sources.ts`, 22 new i18n keys across all 11 locales, MSW handlers, refreshed `openapi-operations.json`.

**Tests**: 40 extraction cases (zip bombs in both a wanted and a skipped entry, billion-laughs, a duplicate part, an encrypted PDF, a scan with no text layer, a misnamed spreadsheet, a legacy Office file, a reordered deck, UTF-16, and text whose first bytes look like an image), a 10-case `IngestedFileStoreContract` run against in-memory, MongoDB and PostgreSQL, 8 `IngestedFileIds` cases, 16 `IngestedFileService` cases, 13 pipeline cases for the upload path, 6 source-removal cases, 14 REST cases, and 10 Manager cases.

Three of them are the ones that matter: a real `.docx` is stored, read by the real extractor, split by the real chunker and answers a real retrieval — and stops answering once it is deleted. Every unit test on the way there can pass while that one fails, which is exactly what happened to the draft this feature builds on: ingestion and retrieval keyed on different names, and nothing noticed, because no test ever performed a retrieval after an ingest.

**Docs**: `docs/rag.md` — the two source types, the upload block, what can be read, every limit and why, the file endpoints, and what a purge, a source removal and a ZIP export each do to stored files.

***

## 🐛 fix(llm): a configuration reference in an LLM parameter no longer breaks its templating (2026-09-21)

**Repo:** EDDI · **Branch:** `fix/llm-config-reference-templating`

`LlmTask` runs the Qute engine over every LLM parameter before the model is built, and escaped `${vault:...}` mentions so a prompt could document the syntax. The other three reference namespaces — `vars`, `connection`, `caller` — were not escaped, although they are resolved the same way: AFTER templating, by `ChatModelRegistry`/`SecretResolver`, with deliberately no Qute namespace resolver.

So a parameter carrying one of them threw on every turn. Observed on a live deployment, where an agent sets `"modelName": "${vars:gemini-model}"` so one global variable drives every agent's model:

```
ERROR Template processing failed for LLM parameter 'modelName':
      No namespace resolver found for [vars] in expression {vars:gemini-model}
```

The turn still worked — the catch keeps the parameter's RAW value, which is exactly right when the value is *only* a reference, and the registry resolves it afterwards. Two things were wrong anyway:

* an ERROR per turn per such parameter, which buries real errors in the log;
* a reference sitting **beside** a real expression abandons the whole render, so `{properties.x}` next to it reaches the model as literal text. Silent, and wrong.

`escapeVaultMentions` becomes `escapeConfigReferenceMentions` and now covers `vault`, the legacy `eddivault`, `vars`, `connection` and `caller`. Httpcall templating is untouched and still fails loudly there, as the `CallerNamespaceResolver` security decision requires.

**Tests:** `LlmTaskVaultMentionTest` — one case per namespace (each asserting the un-escaped form still throws, so the guard cannot go vacuous), the legacy prefix, and the reference-beside-expression case. Narrowing the pattern back to `vault` fails 5 of them with the exact production message.

The escape has two halves that can drift: the regex, and a cheap `contains` pre-check that decides whether the regex runs at all. The pre-check list deliberately omits the legacy `eddivault:`, which is only covered because `"eddivault:"` contains `"vault:"` — correct, but invisible, and a namespace added to the regex alone would silently keep crashing. `preCheckCoversEveryNamespaceInThePattern` derives the namespaces from the pattern instead of restating them and asserts each round-trips, so the halves cannot diverge unnoticed. Mutation-checked both ways: dropping `"vault:"` from the pre-check fails 5 tests; adding a namespace to the pattern alone fails that guard and only that guard.

***

## 🔒 fix(security): sanitize the CWE-117 log sinks PR #799 left uncovered (2026-09-21)

**Repo:** EDDI (`fix/log-injection-log-admin-conversation-store-nats`)

Six open `java/log-injection` alerts on `main` — #105, #106, #112, #113, #114 and #121, across five log statements in three files — that [PR #799](https://github.com/labsai/EDDI/pull/799) did not touch: it closed the 14 alerts in `RestAgentAdministration` and `AgentFactory` only. Like those, these surfaced while triaging the community logger-rename PRs #558 and #561, were correctly judged pre-existing and out of scope there, and were then tracked by nothing at all. Same one-line fix, same test shape.

### What changed

* [**`RestLogAdmin`**](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/internal/RestLogAdmin.java) (#105, #106) — the `streamLogs` "SSE log stream started" DEBUG quoted `agentId` and `level` raw. Both are `@QueryParam`s on `GET /logs/stream`, so both arrive unvalidated. The irony is specific to this endpoint: it *is* the log viewer, so the forged line is served straight back to whoever is tailing the stream. The file gained the static `sanitize` import it did not have. `listenerId` on the same line is left alone deliberately — `BoundedLogStore` generates it, no caller supplies it, and CodeQL did not flag it.
* [**`RestConversationStore`**](https://github.com/labsai/EDDI/tree/main/src/main/java/ai/labs/eddi/engine/memory/rest/RestConversationStore.java) (#112, #113, #114) — the descriptor loop's "Skipping descriptor due to error" DEBUG, and both `deleteAttachmentsForConversation` lines ("Deleted %d attachments" and "Failed to delete attachments"). `conversationId` is the path parameter on `DELETE /conversationstore/conversations/{id}`; the exception message is not the developer's text either, since a store routinely quotes back the value it was handed. The file already static-imports `sanitize` and applies it on the neighbouring permanent-delete, soft-delete and not-found lines, so these three were missed rather than deliberately left.
* **`NatsConversationCoordinator`** (since removed; see `docs/clustering.md`) (#121) — `publishAndExecute`'s "Published to NATS subject" DEBUG. This one logs the *subject*, not the conversation id, which is why the pass that sanitized the rest of the file went past it.

### Tests

Three new classes in the shape `RestAgentAdministrationLogInjectionTest` established — `captureLogsOf(<Class>.class, …)` plus `assertNoForgedRecordBoundary(…)` against `LogCaptureSupport.FORGED_RECORD`, attaching by logger category rather than by field name, so PR #558's renames cannot break them. Seven tests: 2 + 3 + 2.

Each also asserts the benign half of the value still reaches the log, so a "fix" that dropped the argument instead of sanitizing it would fail. All seven of the newly wrapped arguments were mutation-checked one at a time — revert exactly that call, run the class, require the named test to fail. **7 mutations, 7 killed, 0 survived.** The driver lived in the session scratchpad and is not committed.

### Decisions

* **The NATS fix goes at the log call — and, after review, in `sanitizeSubject` as well.** `subject` is `SUBJECT_PREFIX + sanitizeSubject(conversationId)`, and `sanitizeSubject` is not a log sanitizer despite the name: it replaces `.` and space because a NATS subject token may not contain them. The log call is fixed the way the file already does it one method down, where `routeToDeadLetter` logs `sanitize(deadLetterSubject)`.

  This entry first argued CR and LF should be left in `sanitizeSubject`, because widening it would move the subject namespace every deployment already publishes and consumes under. **That was wrong, and the review caught it.** The argument holds for `.` and space, which remap ids that work. It does not hold for CR, LF and tab: `Validator.validateSubjectTerm` in the NATS client refuses a subject containing any of them, so an id carrying one produced a subject the broker never accepted — there was no namespace to move, and every id that works maps where it always did.

  Worse, that rejection is an `IllegalArgumentException`, which is *unchecked*. The publish is wrapped in `catch (IOException | JetStreamApiException)`, whose whole purpose is to degrade to local execution when NATS is unavailable; an invalid subject escapes it instead. Not reachable today — `ConversationService.say` loads the conversation from the store before it ever reaches the coordinator, so an id that is not store-issued fails first — but it cost nothing to close.

  Both sanitizers stay. They answer different questions — what the broker will accept, and what cannot end a log record — and several lines in the file log the conversation id directly, building no subject to be cleaned. The test that pinned "CR/LF survives `sanitizeSubject`" is inverted: it now asserts the method produces a token the broker accepts, and a second test pins that the publish line still logs through `sanitize`.
* **Behavioural tests rather than entries in `SanitizedLogSinksTest`.** That source guard exists for the \~38 group-conversation sinks that need a whole discussion to reach, where building one to observe a single WARN tests the harness more than the fix. All four sinks here are reachable from a public method with mocks, so they get real tests; the guard's hard count of 38 stays honest.
* **A frozen clock in the `RestLogAdmin` test, not a mocked one.** `streamLogs` spawns a cleanup virtual thread that logs its own "listener removed" line. A frozen clock parks it in its 2-second sleep — it can neither reach the heartbeat branch nor fall out of the max-lifetime loop — so the only line inside the capture window is the one under test. Without that the cleanup line could race in and satisfy the assertion in place of the real subject, and the per-site mutation check would stop localising. `@AfterEach` closes the sink so the thread finishes rather than outliving the test.
* **A 24-character hex conversation id in the `RestConversationStore` test.** `extractResourceId` resolves an id only out of a hex path segment; a semantic name yields a null id and the descriptor is skipped at the top of the loop, well before the line under test — a test that would have passed without ever reaching it.

### Still open

`main` carries **85** open `java/log-injection` alerts across 31 files, the largest clusters being `RestScheduleStore` (9), `RestUserMemoryStore` (8) and `VaultSecretProvider` (6). Whether to keep closing them file by file or take one broader pass is a maintainer call and was deliberately not made here. Two adjacent sinks in `RestConversationStore` were also noticed and left, because CodeQL has not flagged them and this branch is scoped to what it did flag: the HITL-cleanup WARN at the top of `deleteConversationLog` sanitizes its `conversationId` but not its `e.getMessage()`, and `populateDataToDescriptor`'s "Memory snapshot not found" WARN quotes `resourceId.getId()` raw.

***

## 🎯 fix(rag): stop embedding queries as documents (2026-09-21)

**Repo:** EDDI (`fix/rag-embedding-input-type`)

Every Gemini knowledge base has been embedding its **queries** as though they were documents, quietly costing retrieval quality. Nothing failed; recall was just worse than the model is capable of.

### The mechanism

Some embedding models are asymmetric — the same text produces a different vector depending on which side of the search it is on. `GoogleAiEmbeddingModel.toTaskType` maps a per-request `EmbeddingInputType.QUERY` to `RETRIEVAL_QUERY` and `DOCUMENT` to `RETRIEVAL_DOCUMENT`, and **when no input type is given falls back to whatever `taskType` the model was built with**.

`RagIngestionService` and `RagContextProvider` both called `embeddingModelFactory.getOrCreate(ragConfig)` with the same configuration, so they hit the same cache entry and shared one instance. `EmbeddingModelFactory` defaults Gemini's `taskType` to `RETRIEVAL_DOCUMENT`. Retrieval therefore embedded the search key as a document.

### What changed

* `getOrCreate` now takes the role as a **required** parameter. Required rather than an optional overload deliberately: a caller cannot forget it, and the compiler names every site that has to choose. There are three: retrieval, and the two ingestion paths.
* The role is part of the cache key — to an asymmetric provider the two roles are two different models, and sharing one entry is the defect.
* `InputTypedEmbeddingModel` (new) attaches the role via `defaultRequestParameters()`, which is what carries it through the `embed(String)` convenience overload that `EmbeddingStoreContentRetriever` uses. There is no way to pass a per-call parameter through that retriever, so the role has to travel with the instance.

### Why the capability check is not optional

`EmbeddingModel.embed` validates against `supportedParameters()` and throws `UnsupportedFeatureException` for anything unsupported **rather than ignoring it**. Verified against the resolved jars: only **2 of the 8** providers EDDI builds declare `INPUT_TYPE` — `gemini` and `cohere`. Setting it unconditionally would have turned every OpenAI, Azure, Ollama, Bedrock, Vertex and Mistral knowledge base into a hard failure. Those six are handed the provider's model unchanged.

The check reads the live model's own `supportedParameters()` rather than a table of provider names, so it cannot go stale on a dependency bump. Mutation-tested: removing it produces exactly `UnsupportedFeatureException: ... does not support the following per-call parameter(s): inputType`.

### No re-ingestion needed

Stored vectors were always correct — ingestion's implicit `RETRIEVAL_DOCUMENT` was the right value for documents. Only the query side was wrong, so existing knowledge bases improve on the next query with no migration.

### Tests

`InputTypedEmbeddingModelTest` (9, new) covers both roles reaching the provider, the unsupported-provider passthrough, delegation, and that attaching a role does not drop the delegate's `modelName`/`dimensions`. Its first test pins the *defect* — an undecorated model reports no input type at all. Two call-site tests assert ingestion asks for `DOCUMENT` and retrieval asks for `QUERY`, which is what makes the fix real: the decorator is useless if both sites still ask for the same thing.

`EmbeddingModelFactoryTest.PinnedTaskTypeDecision` (7, new) grades `pinsNonRetrievalTaskType` directly. It is package-private and tested on its own because every path through `build()` constructs a live provider client, which needs a socket — so the model-level tests only run where one is available, and the decision itself has to be gradeable anywhere. `PinnedTaskType` (6, new) covers the same decision through `getOrCreate` and runs in CI.

`docs/rag.md` gained an "Asymmetric models" section covering which providers are affected, what happens to a pinned `taskType`, and that no re-ingestion is required.

### Follow-up: a pinned Gemini `taskType` is no longer overridden by the role

Raised in review of [#810](https://github.com/labsai/EDDI/pull/810). Attaching the role to every RAG call fixes the query side, but `GoogleAiEmbeddingModel.toTaskType` falls back to the build-time `taskType` **only when no input type is given**; a role maps unconditionally onto `RETRIEVAL_QUERY` / `RETRIEVAL_DOCUMENT`. A knowledge base configured with `taskType: SEMANTIC_SIMILARITY` (or `CLASSIFICATION`, or `CLUSTERING`) would therefore have stopped sending it, and everything ingested afterwards would have used `RETRIEVAL_DOCUMENT` — two incompatible geometries in one index, with nothing failing to say so.

`EmbeddingModelFactory.pinsNonRetrievalTaskType` now detects that case and leaves the role off, so the configured task type keeps reaching the provider on both sides. Three boundaries are deliberate:

* **`RETRIEVAL_DOCUMENT` and `RETRIEVAL_QUERY` do not pin.** The first is the default this factory applies and is precisely the value that produced the defect; honouring it would leave the bug in place for anyone who had written the default out by hand.
* **`gemini-embedding-2` never pins.** langchain4j sends no `task_type` for any model whose name contains `embedding-2` and applies a role instruction instead, so a pinned task type is already inert there and skipping the role would cost the instruction for nothing.
* **`taskType` is a Gemini parameter.** A stray one on Cohere or OpenAI pins nothing.

### Follow-up: an unconfigured embedding provider names itself

Raised by the static-analysis reviewer on [#810](https://github.com/labsai/EDDI/pull/810). `EmbeddingModelFactory.build` switches on the provider string, and `RagConfiguration.validate()` guards its own provider check with `embeddingProvider != null && !embeddingProvider.isBlank()` — it rejects providers it does not *recognise*, not ones that are absent. A knowledge base saved with an explicit null `embeddingProvider` therefore saved cleanly and only failed at first use, as a bare `NullPointerException` thrown by switching on null from inside the factory, naming nothing the operator could act on. A blank provider already fell through to the switch's `default` and was reported properly; null now says the same thing.

The supported-provider list moved into one `SUPPORTED_PROVIDERS_HINT` constant shared by both rejections, so the absent-provider and unrecognised-provider messages cannot drift apart as providers are added.

### Follow-up: the crawler ingestion path, which `main` added underneath this branch

`main` gained `IngestionPipeline` (the crawl-and-ingest path) while this branch was open, and it calls `embeddingModelFactory.getOrCreate(knowledgeBase)`. That is a third call site for a method this branch had made two-argument, so the merge of the two did not compile — which is the required parameter doing exactly the job it was chosen for. An optional overload would have merged silently and left the crawler sharing retrieval's cache entry, reintroducing the original defect on the one ingestion path that did not exist when the defect was found.

`IngestionPipeline.Collector.model()` now asks for `DOCUMENT`; it is storing vectors. `IngestionPipelineTest` pins that directly, and `IngestionRetrievalRoundTripTest` — which drives a real crawl and a real retrieval against one factory — now records the role each half asks for and asserts the pair is `[DOCUMENT, QUERY]`. That assertion is the one that would have caught this: the two halves sharing a role is the whole defect, and the round-trip test is the only place both halves are visible at once.

***

***

## 🐛 fix(rag): a scheduled ingestion source was stored enabled and never ran (2026-09-21)

**Repo:** EDDI (`fix/rag-ingestion-schedule-never-fires`) — backend, `ui/manager/`, docs

Five defects that reached `main` because [PR #790](https://github.com/labsai/EDDI/pull/790) was merged with its review threads still unresolved. The first one made the feature that PR added not work at all.

### 1 — the schedule had no fire time, so no poll could ever select it

`RagSourceIngestionService.buildSchedule` built a CRON `ScheduleConfiguration` and never set `nextFire`. Neither store computes one: `MongoScheduleStore.createSchedule` and `PostgresScheduleStore.createSchedule` both persist whatever they are handed. Both `findDueSchedules` implementations then select on `enabled = true AND nextFire <= now`, and a null `nextFire` matches neither backend's comparison — BSON type bracketing excludes null on Mongo, and `next_fire <= ?` is UNKNOWN for NULL on Postgres. So a source with a cron was stored reading back `enabled`, showed as scheduled on every screen, and **never fired**.

`buildSchedule` now arms the schedule the way `RestGroupWorkspace.addCadence` does, with the same time-zone handling: the cron is read in a fixed **UTC**, and `timeZone` is written onto the row rather than left null. That second half matters — `SchedulePollerService` re-arms a fired schedule through `resolveTimeZone(schedule.getTimeZone())`, which falls back to the deployment's `eddi.schedule.default-timezone`, so a first fire computed in UTC and every later fire computed in some other zone would have drifted by the offset, once, silently.

**Where the fix belongs.** In the caller, not in `createSchedule`:

* Putting it in `MongoScheduleStore.createSchedule` would **not** cover Postgres. The two stores share no base class, so "fix the store" means writing it twice — and fixing only the Mongo one leaves a Postgres deployment broken while looking fixed.
* The arming *policy* already exists, once, in `RestScheduleStore.computeRearmNextFire`: heartbeat interval, one-shot `oneTimeAt`, the unsatisfiable-cron translation, the configured default zone. A second, subtly different copy of it inside the persistence layer is exactly the divergence `MongoScheduleStore.updateSchedule`'s own comments record as having bitten before.
* A store that silently rewrites the object it is given also hides real bugs: `markCompleted(id, null)` *means* "one-shot finished, disable it".

Every other direct-to-store creator already arms its own schedule (`RestGroupWorkspace`, `ConversationHitlService`, `GroupHitlCoordinator`, `HitlCrashRecoveryObserver`). This one had simply forgotten, and that is where it is fixed.

**Existing rows are already broken**, so a fix to `buildSchedule` alone would leave every schedule created before it dead for ever — repaired only if somebody happened to re-save the knowledge base, which nothing would tell them to do. A startup sweep (`RagSourceIngestionService.repairUnarmedSchedules`, `@Observes StartupEvent`, new `eddi.rag.ingestion.schedule-repair.enabled`, default on) gives a fire time to any *ingestion* schedule that is enabled, carries a cron and has no `nextFire` at all. It is safe to run repeatedly by construction: after the first pass nothing matches. It arms through the existing store-agnostic `setScheduleEnabled`, so it needs no new store method, and it touches nothing outside this feature's metadata — a schedule left unarmed on purpose is not a thing this repair can invent a cadence for.

**Two corrections from review (Copilot, #818).** The first version of this paragraph claimed two nodes "compute the same next occurrence", which is only true if both compute inside the same cron period. They need not: the listing is a snapshot and `setScheduleEnabled` overwrites `nextFire` unconditionally, so a slower node crossing a cron boundary could replace the first node's occurrence with the following one and skip a fire. Arming now goes through **`IScheduleStore.armIfUnarmed`**, which carries the "still unarmed" condition in the write predicate itself — `AND next_fire IS NULL AND enabled=true` on the Postgres `UPDATE`, `eq(NEXT_FIRE, null)` in the Mongo filter, which matches a stored null and a missing field alike. That is the only place the two nodes meet, so the first writer wins and every other one is a no-op that reports `false`; a lost race is treated as success, because the row is armed either way. An earlier draft re-read the row before writing instead, which narrowed the window to one store round-trip without closing it.

**A third, from CodeRabbit (#818): the repair could not arm in UTC, and should not have tried.** `buildSchedule` writes `timeZone` for rows it creates, but the repair arms through `IScheduleStore.setScheduleEnabled`, which takes only `enabled` and `nextFire` — a legacy row's null `timeZone` stays null whatever the repair does. Every fire after the first is therefore re-armed by the poller through `resolveTimeZone(null)`, the deployment's `eddi.schedule.default-timezone`, so computing the first fire in UTC regardless would hand a non-UTC deployment exactly one interval of the wrong length — the same drift this PR fixed in `buildSchedule`, arriving on the repair path instead. The repair now reads the cron in the zone the poller will use for that row (its own if it names one, the deployment default otherwise), which is the only choice that makes the row internally consistent without a new store method. CodeRabbit's alternative — widen the store's re-arm to carry a zone — would normalise legacy rows to UTC as well, at the cost of a store-API change implemented twice; noted, not taken.

The second: the 40-page bound used to end the walk **silently**, so past 20,000 schedules an operator read "armed 12 schedules" with no way to tell a finished repair from one that stopped a page short of the row they were waiting on. `repairUnarmedSchedules` now returns `RepairResult(armed, complete)` and logs a warning naming the bound when it is the reason the walk stopped. The bound itself stays: an unbounded scan at startup is the thing it exists to prevent.

Also refused now: a cron that parses but can never match a date (`0 0 30 2 *`). `CronParser.validate` accepts it; `computeNextFire` gives up after two years with an `IllegalStateException`. Stored, it was another source that showed as scheduled for ever.

### 2 — a ZIP could store a cron the REST API refuses

`RestImportService.createNewRags` writes straight to the store, so `RestRagStore.prepareForWrite` never runs. Its `prepareImportedRag` assigned source ids and called `RagConfiguration.validate()`, which does not look at the cron — so an archive could carry a six-field Quartz expression that `POST /ragstore/rags` rejects with a 400.

The rule moved out of `RestRagStore` into `RagIngestionSchedules.requireValidCrons` and both paths call it. A second copy of the check would only have been a second chance to forget it.

**Reconciling `schedule.setNextFire(null)` with defect 1.** That line, in `prepareScheduleForImport`, is about an *agent's* schedules from the archive's `schedules/` directory, and it is correct: every write on that path goes through `IRestScheduleStore`, which re-arms the schedule before storing it, and an archived `nextFire` is either long past (the schedule fires during the import) or absent. The two are not in contradiction — they are the same rule seen from both sides: **nothing in either store computes a fire time, so whoever writes has to arm.** Via the REST bean, the bean does it; direct to `IScheduleStore`, the caller does it. The javadoc now says so explicitly and names the ingestion sync as the path that did not.

### 3 — run reports named a null source

`IngestionPipeline` used `source.getId()` at six call sites — the failed/skipped/already-running reports, the abandoned reservation, the released reservation and `toReport`. A source that arrived without an id is addressed, keyed and scheduled by its **name** everywhere else (`IngestionSource.effectiveId()`, which `stateKey` already used), so those six reports carried `sourceId: null` into the run history, the REST answer and the fire log. All six now use `effectiveId()`, the record component documents the contract, and `RagSourceIngestionService.sourceIdOf` delegates to `effectiveId()` rather than keeping a third copy of the rule.

### 4 — `docs/rag.md` said every ingestion field has a default

It does not, and the table three lines below said so itself (`startUrl` | required). Corrected against the code: `name`, the `web` block and its `startUrl` have no default and are rejected when missing; `type` defaults to `web`; `id` is generated; `cron` and `costPerThousandSegments` are simply absent when unset. `userAgent`'s "EDDI's default" is now the actual string. The cron paragraph gained the unsatisfiable-expression rule, the import path, and the UTC note.

### 5 — a Manager test passed for the wrong reason

`resource-detail-rag-sources.test.tsx` → "does not offer Run for a disabled source" clicked the enabled toggle and then asserted the Run button was disabled. Toggling marks the editor dirty, and `hasUnsavedChanges` disables Run on its own — so the assertion held with `source.enabled === false` deleted from the button entirely. The source now **arrives** disabled from an MSW override, nothing is dirty, and the test additionally asserts that the unsaved-changes hint is absent and that Preview — gated on the same read-only and dirty guards but not on `enabled` — is still offered. Only the disabled-source rule can explain the result.

### Mutation checks

| Mutation                                                             | Test that failed                                                                                           |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `buildSchedule` drops `setNextFire`                                  | `RagSourceIngestionServiceTest` → "a source with a cron is due once its fire time arrives"                 |
| `buildSchedule` drops `setTimeZone`                                  | "the fire time is the cron read in UTC, and the schedule says so"                                          |
| `repairUnarmedSchedules` returns early                               | "an ingestion schedule with no fire time is given one"                                                     |
| `prepareImportedRag` drops `requireValidCrons`                       | `RestImportServiceRagCronTest` → both refusal cases                                                        |
| Run button drops `source.enabled === false`                          | "does not offer Run for a source that is saved as disabled"                                                |
| `armIfUnarmed` is replaced by the unconditional `setScheduleEnabled` | "a row another node armed while the sweep was listing is left alone"                                       |
| The walk always reports itself complete                              | "stopping at the page bound is reported, not swallowed"                                                    |
| The repair arms in fixed UTC instead of the poller's zone            | "a legacy row with no zone is armed in the zone the poller will use, not in UTC" (expected hour 2, got 11) |

### Files

`modules/ingestion/RagSourceIngestionService.java`, `modules/ingestion/RagIngestionSchedules.java`, `modules/ingestion/IngestionPipeline.java`, `configs/rag/rest/RestRagStore.java`, `backup/impl/RestImportService.java`, `docs/rag.md`, `docs/configuration-reference.md`, `ui/manager/src/pages/__tests__/resource-detail-rag-sources.test.tsx`. Tests: `RagSourceIngestionServiceTest` (two new nested groups), `RestImportServiceRagCronTest` (new).

***

## 🧩 chore(changelog): per-branch fragments instead of one shared file (2026-09-21)

**Repo:** EDDI (`chore/changelog-fragments`)

### The problem

[`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md) §2 rule 8 requires every branch to add a changelog entry, and it said to add it **directly below the `---` that closes the header** of [`changelog.md`](/reference/changelog.md). Every open PR therefore inserted text at the same point in the same file. Git has no way to merge two insertions at one point, so with a dozen PRs in flight every one of them conflicted with every other — over a document that had nothing to do with the code under review. Resolving it rebased the branch, which re-ran the identical collision at the next merge. The two running registers at the bottom of the file (`## Decision Log`, `## Regression Notes`) behaved the same way: a fixed append point in a shared file.

The rule was right and the storage was wrong. Nothing about "every session records what it did" requires every session to write into the same twenty lines.

### The shape of the fix

An entry is now a **new file** in [`docs/changelog.d/`](/reference/changelog/changelog.d.md), named `YYYY-MM-DD-<slug>.md` with the slug unique to the branch. Two branches adding an entry add two files, and git takes both without asking. The entries are merged **once, afterwards, on `main`**, by [`changelog-collate.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/changelog-collate.yml) — where there is no competing branch to conflict with. The changelog gains a third depth:

```
docs/changelog.d/<date>-<slug>.md   pending, written by a PR
        |  collate-changelog.py  (nightly)
        v
docs/changelog.md                   the live file, newest first
        |  rotate-changelog.py   (nightly, when over the 200 KB target)
        v
docs/changelog/<YYYY-MM>.md         monthly archive
```

**The fragment format is what used to be pasted into the live file**, so collation is a move rather than a translation — the entry that lands in `changelog.md` is byte-for-byte the one that was reviewed on the PR that wrote it, apart from link depth. That mattered more than a tidier format would have: a collator that reformats is a collator whose output has to be re-reviewed.

### Decisions

**Links change depth, and the transform refuses rather than guesses.** A fragment sits one directory below `changelog.md`, exactly like an archive sits one below it in the other direction, so collation removes one `../` and rotation adds one back. Both live in the new [`changelog_common.py`](https://github.com/labsai/EDDI/tree/main/scripts/changelog_common.py) rather than being written twice, masking code spans and fenced blocks out of the substitution — some of the text that moves is documentation *of* link syntax, and re-depthing an example corrupts it. `undepth()` **exits** on a link with no `../` to remove instead of passing it through: that case is a fragment linking to a sibling, which resolves where it is written (so `DocumentationLinksTest` passes it) and breaks the moment the entry moves up — in the bot's commit, days later, blamed on the bot.

**Register rows ride along in fenced blocks.** ` ```decision-log ` and ` ```regression-note ` blocks anywhere in a fragment are lifted out and inserted at the top of the matching table. A heading would have been prettier to write, but `split_sections()` keys on the heading text: a bare `## Decision Log` is classified as the register itself, so the section is filed below the bottom rule instead of being collated, and a dated `## Decision Log (2026-09-22)` collates normally and is then *reclassified on the next run* — moving an entry that has already been reviewed and merged, with no commit to explain it. `REGISTER` is anchored to the exact heading and `ChangelogFragmentTest` rejects both spellings in a fragment, so neither half depends on the other being right.

**Every scan is fence-aware.** A changelog entry routinely quotes the markdown it describes, and the first draft read a `##` inside a fenced block as a heading and a ` ```decision-log ` inside a ` ````markdown ` example as a real register block — splitting an entry at a code line, and filing an example row into the live Decision Log while gutting the fence around it. `fence_mask()` closes a fence only on a run of the **same character**, at least as long as the one that opened it, and the heading scan, the register scan and both link transforms all go through it.

CommonMark allows `~~~` as well as \`\`\`, and the first version of that mask knew only about backticks — so a `~~~markdown` example was not a fence at all, and everything inside it was read as structure. Both are recognised now, in the scripts and in the test. The test had the mirror-image bug: `registerRowsCarryADate` opened only on a register fence, so it graded the placeholder row in the README's own nested example as real and was *stricter* than the collator, against a class Javadoc promising it enforces exactly what the collator enforces.

**The nightly job opens a PR and skips while one is open.** `main` requires a PR, and a bot pushing straight to it would be a hole in that requirement even where the token allows it. The skip matters more than it looks: a still-open collation PR has already claimed those fragments — deleted on its branch, still present on `main` — so collating again would produce a second PR proposing the same entries, and whichever merged second would conflict. That would reintroduce, inside the fix, the exact failure the fix exists to remove. For the same reason the open-PR lookup **fails the job** rather than reporting "none" when the API call errors: the next step deletes the branch, so a lookup that returns an empty string on failure would close the PR it exists to protect.

**The PR needs a non-default token to be mergeable, and says so when it does not have one.** GitHub does not fire `pull_request` workflows for events created with the default `GITHUB_TOKEN`, so a PR it opens sits at *Expected — waiting for status* against the required `Build & Test` and `CodeQL Analysis` checks forever. `base-image-check.yml` has the same shape and has never actually been exercised — the repository has no PR authored by `github-actions`. The job therefore prefers a `CHANGELOG_BOT_TOKEN` secret and, without one, still opens the PR but states in the run summary and the PR body that the checks need a close/reopen. A silently unmergeable nightly PR would stall the whole mechanism on day one.

**Adding an entry in place now fails CI.** The `Changelog Discipline` job rejects a PR that *adds* an entry heading or a dated register row to `docs/changelog.md`. Prose in AGENTS.md is what every session follows, but it is not a guard — and twenty-nine PRs were open against the old rule when this was written.

Two things the first draft of that job got wrong, both found by review. It required a closing `)` after the date while `DATE` deliberately does not, so the fourteen entries headed `(2026-07-02, session 2)` or `(2026-04-08 cont.)` — the house style — walked straight past a guard that the collator would still have treated as entries. And it counted additions only, so correcting a typo in a past entry's heading failed, with a summary telling the author to move their correction into a new fragment. It now compares added against **removed**: an edit is one `+` and one `-`, nets to zero, and is allowed; rotation removes entries and nets negative; only a genuine insertion raises the count. All ten cases are exercised. It is deliberately not a required check — it reports rather than blocks, which matters while those twenty-nine PRs are still open.

**Rotation now maintains `SUMMARY.md` itself.** A new archive that is not listed there fails `DocumentationLinksTest` — the page exists and nothing navigates to it. That used to be a printed reminder at the end of the script, which is a step a tired human skips and an automated rotation cannot perform at all. It is regenerated from the files on disk, like the Archive table beside it.

### Keeping the guards pointed at the right files

`docs/changelog.md`, `docs/changelog.d/**` and `docs/changelog/**` were added to `ci.yml`'s `operator_docs` path filter. The `code` and `backend` filters both exclude `docs/**`, and the nightly collation PR touches nothing else — so the one PR that rewrites the entire changelog was the one PR that would have skipped `build-and-test` entirely, and a skipped required check still satisfies branch protection.

Two existing guards needed the new directory carved out, each with a compensating assertion:

* `DocumentationLinksTest` requires every page under `docs/` to be reachable from `SUMMARY.md`. Fragments are pending entries, not pages, and live about a day. The exemption is kept narrow by `ChangelogFragmentTest`, which allows nothing but a `README.md` and dated fragments in that directory — so it cannot become somewhere to park a real page.
* `DocumentationAccuracyTest` already skips `changelog.md` and its archives, because they record what was true at the time. A fragment is the same historical record one step earlier; without the exclusion its assertions would fire on an entry and stop firing on the identical text the next morning. `DocumentedRestPathsTest` and `ConfigurationReferenceCoverageTest` carry the same pair of exemptions and needed the same third one, for the same reason. `changelog.d/README.md` is **not** exempted from the accuracy sweep: it is a current page that documents the format, not a record.

`ChangelogFragmentTest` also asserts that `AGENTS.md` still sends sessions to `changelog.d/`. Every session follows the instruction it is given, and a convention nobody is told about fixes nothing.

### Entries merge by date, and the separator stops multiplying

Two bugs a sandbox caught that reading would not have. The first draft **stacked** the collated block above everything, so a PR that sat open for three weeks pushed a three-week-old entry above last night's — in a file whose whole contract is newest-first. Entries are now woven into the existing list by date, ties placing the new one first.

The second: `split_sections()` hands back the last entry's body *including* the `---` that closes it, and the writer appended another one. Every collation added a rule, permanently — 48, 49, 50, 51 over three nightly runs in the sandbox — and rotation only reset the count on the runs that happened to move the last entry. `register_separator()` now emits one only when the body does not already carry it.

### Also fixed in passing

* The **Regression Notes** table had a header row and no `|---|` separator, so it had never rendered as a table. The collator writes into it, so it needed one.
* Dates are checked against the calendar, not just bounded by a regex. `MONTH` and `DAY` stop `2026-99-99` (which used to sort lexically into the live file and then crash rotation inside `pretty_month()`, weeks later, in the nightly job), but they still admit `2026-02-30` — so both the scripts and the test now parse the date as well.
* `--check` validates the live file it would write into, not only the fragments. Returning early meant a `changelog.md` missing a register section, or a table missing its `|---|` row, passed "validate, change nothing" and then failed the real run — on main, with nobody's change to blame.
* A UTF-8 BOM no longer reports a fragment as having no `##` heading when the heading is plainly there; `read()` uses `utf-8-sig` and the test strips it. The BOM constant in the test is written numerically on purpose — the project formatter rewrites a `\uXXXX` escape into the raw character, which would have put an invisible BOM into the source file.
* Register rows are sorted newest-first before insertion. They arrive in fragment-filename order — oldest first — and were inserted as one block at the top, so a night that collated several days' fragments would have put 09-20 above 09-21 inside a table whose whole ordering is newest-first.
* A row bound for a register must lead with a real date, because that date is what places it. The three legacy `2026-03-05` rows at the top of the **Decision Log** were left where they are: moving rows reads as adding them, and the `Changelog Discipline` job below would have rejected this PR over a purely cosmetic reorder.
* `update_summary()` regenerates the month list in `SUMMARY.md` between the anchor and the first line that is not an archive row. Putting the *Pending entries* link directly under the anchor — the natural place for it — would have had the regenerated months inserted above it and the old rows kept below, silently doubling the list; every duplicated link still resolves, so nothing would have caught it. It now consumes every child of the anchor and re-emits the non-month ones after.

### Migrating the PRs already open

Twenty-nine open PRs carry an entry in `docs/changelog.md` under the old rule, and seven of them also carry a branch-local rotation of `docs/changelog/2026-09.md`. Each needs its entry cut into a fragment — the steps are in [`docs/changelog.d/README.md`](/reference/changelog/changelog.d.md) — which also removes the conflict that branch currently has with every other open PR. [`.github/PULL_REQUEST_TEMPLATE.md`](https://github.com/labsai/EDDI/tree/main/.github/PULL_REQUEST_TEMPLATE.md) and [`ui/chat/AGENTS.md`](https://github.com/labsai/EDDI/tree/main/ui/chat/AGENTS.md) were updated to point at the new location; the `planning/*.md` documents that mention editing the changelog are historical plans and were left as written.

**Files:** [`scripts/changelog_common.py`](https://github.com/labsai/EDDI/tree/main/scripts/changelog_common.py), [`scripts/collate-changelog.py`](https://github.com/labsai/EDDI/tree/main/scripts/collate-changelog.py), [`scripts/rotate-changelog.py`](https://github.com/labsai/EDDI/tree/main/scripts/rotate-changelog.py), [`.github/workflows/changelog-collate.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/changelog-collate.yml), [`.github/workflows/ci.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/ci.yml), [`ChangelogFragmentTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/ChangelogFragmentTest.java), [`DocumentationLinksTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/DocumentationLinksTest.java), [`DocumentationAccuracyTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/DocumentationAccuracyTest.java), [`DocumentedRestPathsTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/DocumentedRestPathsTest.java), [`ConfigurationReferenceCoverageTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/docs/ConfigurationReferenceCoverageTest.java), [`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md), [`docs/changelog.md`](/reference/changelog.md), [`docs/changelog.d/README.md`](/reference/changelog/changelog.d.md), [`docs/SUMMARY.md`](https://github.com/labsai/EDDI/tree/main/docs/SUMMARY.md), [`.github/PULL_REQUEST_TEMPLATE.md`](https://github.com/labsai/EDDI/tree/main/.github/PULL_REQUEST_TEMPLATE.md), [`ui/chat/AGENTS.md`](https://github.com/labsai/EDDI/tree/main/ui/chat/AGENTS.md)

## 🚧 fix(ingestion): a reaped run can no longer write over the run that replaced it (2026-09-21)

**Repo:** EDDI (`feat/ingestion-state-store`)

### Why

Review of PR #785 found a write-after-reap hole in the ingestion state store, and left it open because no fix fitted in that PR's shape. Worker A holds the `RUNNING` run row for a source, stalls past the stale threshold, and `reapStaleRuns` fails its run. Worker B claims a replacement run. A then wakes up and calls `recordIngested` / `recordSeen` / `recordUnreachable` with a run id that is dead, mutating document rows that now belong to B.

The severity was originally judged bounded — a late `recordIngested` sets `missedRuns = 0` and `tombstoned = false`, so it preserves a document rather than losing one. Writing the contract cases turned up two worse paths that the "it only keeps data" reading misses:

* **Silent, permanent loss of a page.** Tombstoning deletes a document's vectors. A stale `recordIngested` lifts the tombstone *and* restores the hash those deleted vectors matched, so `hasChanged` reports "unchanged" on every later run and the page is never embedded again. It is gone from retrieval for good, with nothing in any log.
* **Vector deletion of a live page.** `recordUnreachable` writes only the run marker, which looks harmless until you follow it: stamping that marker is exactly how a document escapes the owning run's miss count. A zombie overwriting it hands a page the live run has just seen back to `tombstoneMissing`.

### What changed

A fencing token both backends apply **in the same statement as the document write**, because the alternatives re-introduce backend divergence: MongoDB cannot join collections in an update, and multi-document transactions need a replica set while EDDI supports standalone MongoDB (`docker-compose.yml` ships `mongo:7` standalone). A PostgreSQL-only `AND EXISTS (SELECT 1 FROM rag_ingestion_runs …)` fence would leave the two stores behaving differently — the exact drift `IngestionStateStoreContract` exists to prevent.

Ownership is denormalized onto the document row instead:

* `fencing_run_id` / `fencingRunId` — the run that owns the row. `startRun` takes ownership of the source's rows when it claims a run; `reapStaleRuns` releases it (to `NULL`) when it actually reaps something, so a reaped worker is fenced from that moment rather than only once a replacement claims.
* `fencing_generation` / `fencingGeneration`, paired with a new `generation` on the run row — the claim's sequence number, which orders the ownership stamps themselves. Without it, a stamp delayed past its own run's reaping could land after the replacement's and take the source back.
* Every `record*` and `tombstoneMissing` call puts its `runId` into the update's own filter. **No signature changed and the caller makes no extra round trip** — the `runId` the pipeline already passes *is* the fencing token. PostgreSQL uses `ON CONFLICT … DO UPDATE … WHERE fencing_run_id = EXCLUDED.fencing_run_id`; MongoDB puts the field in the upsert filter and reads the resulting `(sourceId, documentId)` duplicate-key error as the same answer.

A fenced write is a no-op logged at DEBUG, not an exception: the reaper already decided the run is dead and `finishRun` logs once that its result was discarded, so failing every document of a doomed crawl would only add noise to a result that is thrown away.

**What the fence does not cover,** stated in the interface Javadoc rather than glossed: a document the superseded run is the first ever to see has no row to own, so its insert still lands. That is the benign direction, and the owning run's `tombstoneMissing` reconciles it away over the following runs.

### Design decisions

* **Ownership stamped at claim, not propagated lazily.** Comparing a per-document generation on the write alone only fences rows the *replacement* run has already touched — a zombie would still be free to write every row the live run had not reached yet, which is most of them early in a crawl. Taking ownership of the source's rows at claim time costs one bulk update per run and is the only shape that fences the whole source.
* **`fencing_run_id` is separate from `last_run_id`.** They mean different things: `last_run_id` is the run that last *wrote* the row and drives the miss count; ownership is about who is *allowed* to write. Overloading one field would have made `recordUnreachable` grant itself the write permission it is being checked for.
* **`ALTER TABLE … ADD COLUMN IF NOT EXISTS` alongside the `CREATE TABLE`.** `CREATE TABLE IF NOT EXISTS` is a no-op against a database an earlier build of this branch already created, which would have left the fence silently un-enforceable there.

### Verified

`MongoIngestionStateStoreTest` and `PostgresIngestionStateStoreTest` — 38 cases each, both green. Non-vacuity checked by reverting `src/main` and re-running: **6 of the 7 new cases fail identically on both backends** (the seventh, `fencingIsScopedPerSource`, is the guard against over-fencing and must pass either way).

### Follow-up for the downstream stack

`InMemoryIngestionStateStore` (test double, added in #787) implements the same contract, so it will need the same fence when this branch merges forward into #787 / #789 / #790. Nothing in `IngestionPipeline` needs to change — preview mode never calls `record*`, and a reserved run id comes from `startRun` like any other.

### Files

* `src/main/java/ai/labs/eddi/modules/ingestion/IIngestionStateStore.java`
* `src/main/java/ai/labs/eddi/modules/ingestion/mongo/MongoIngestionStateStore.java`
* `src/main/java/ai/labs/eddi/datastore/postgres/PostgresIngestionStateStore.java`
* `src/test/java/ai/labs/eddi/modules/ingestion/IngestionStateStoreContract.java`

***

### Review follow-ups (2026-09-21)

* **The reaper released ownership for the whole source, not for the runs it reaped.** `reapStaleRuns` fails the stale runs and clears `fencing_run_id` in two writes, not one. Once the first commits, the partial unique index on the source is free: a replacement run can claim the source and stamp every document with its own id before the second one runs, and a source-wide release then wipes the live run's fence. Every `recordSeen`, `recordIngested`, `recordUnreachable` and `tombstoneMissing` of that run silently matches nothing while it carries on crawling and embedding, so it finishes looking healthy having recorded no document at all, and the whole source is re-fetched and re-embedded on the next run. PostgreSQL now reaps with `UPDATE ... RETURNING run_id` and releases `WHERE fencing_run_id = ANY (?)`; MongoDB claims each stale run with `findOneAndUpdate` — the ids have to come back *with* the write, not from a read after it — and releases with `in(fencingRunId, reapedIds)`.
* **`startRun`'s insert was the one operation still outside `translating(...)`.** It handled `MongoWriteException` and let everything else out, so a connection failure, a timeout or a step-down during the insert escaped as a raw `MongoException` while PostgreSQL answered the same outage with `IngestionStateStoreException` — the backend-dependent exception contract the helper was added to remove. The duplicate-key case is now handled inside the supplier, so it still returns `Optional.empty()`, and everything else falls through to the helper.

The contract gained `reapingDoesNotReleaseAnotherRunsOwnership` and one hook, `forceDocumentOwner`, implemented per backend. The hook is needed because the interleave cannot be produced through the public interface: the partial unique index means a stale `RUNNING` run and its replacement can never both exist, so there is no sequence of `startRun` calls that leaves a document owned by a run other than the one about to be reaped.

### Still open

Review also found that `recordIngested` inserts a newly discovered document with a `fencing_run_id` but no `fencing_generation`, and `takeOwnership` matches `fencing_generation IS NULL` (the arm that exists for rows predating the fencing columns). A `takeOwnership` delayed past its own run's reaping can therefore land on a row the replacement run inserted and take that one document back. The scoped release above does not close it. Every fix needs the claiming run's generation at insert time, which `recordIngested` is not given: a scalar subquery works on PostgreSQL and has no MongoDB equivalent, a per-call lookup is symmetric but adds a read per ingested document, and carrying the generation through `startRun`'s return type is the cleanest but changes `IIngestionStateStore`, which two further open PRs are built on. Left for that decision rather than picked unilaterally here.

### Merging `main`: two designs for the same concern

`main` reworked tombstoning while this branch was open, and the two changes met head-on. This branch made `tombstoneMissing` atomic -- `UPDATE ... RETURNING` on PostgreSQL, `findOneAndUpdate` in a loop on MongoDB -- so two runs finishing together could not both report the same document as newly gone. `main` split it instead, into `bumpAndFindMissing` + `markTombstoned`, so a caller can delete the vectors *before* marking: marking first is durable in the wrong order, because a crash between the two leaves a document flagged gone while its chunks stay retrievable, and a tombstoned document is never reported again.

**`main`'s split is kept, and this branch's fencing is grafted onto it.** The two rationales are not symmetric: the durability ordering is a property nothing else provides, whereas the atomicity the `RETURNING` clause bought is already delivered by the fence. Only the owning run satisfies `fencing_run_id = ?`, so two runs cannot both reach the same document to report it in the first place. Keeping both would have meant choosing the weaker guarantee and losing the stronger one.

Three consequences, each of which is now pinned by a test:

* **The search is fenced, not just the bump.** Fencing the miss counter alone leaves a hole: the counter is shared, so a document another run has already bumped to the threshold still satisfies an unfenced search. A superseded run would hand its caller a list of documents to delete, and the caller deletes the vectors before anyone checks who owned them. Both statements now carry the predicate, in both backends. This was not covered -- removing the predicate from the PostgreSQL search left all 39 contract cases green -- so `supersededRunReportsNothingMissing` was added to the shared contract, where all three implementations run it.
* **`tombstoneMissing` reports the transition it just performed.** `bumpAndFindMissing` reports its candidates *before* marking them and `DocumentState` is immutable, so the convenience default was handing back a list that still said `tombstoned=false` although marking had succeeded. A caller that believed it would re-report the same documents on the next run. `DocumentState.asTombstoned()` restamps them.
* **The in-memory double was fenced too.** `main` added `InMemoryIngestionStateStore` with no fencing, which would have left the double behaving differently from both backends on exactly the property this branch exists to add -- the failure the shared contract was written to prevent. It now takes ownership on `startRun`, fences all four document writes, and releases only the ownership a reap actually took. All three implementations run the same 39 cases.

The double deliberately reproduces one thing it could have quietly fixed: a document row inserted for the first time lands with an owner and **no** generation, because that is what both backends do. Stamping the claiming generation there is the subject of an open review thread and is a sequencing decision, not a merge one -- it changes `IIngestionStateStore`, which two further PRs are built on.

***

## 🐛 fix(llm): Jlama was running on the scalar fallback in every JVM we ship (2026-09-20)

**Repo:** EDDI (`fix/jlama-vector-api`)

The `jlama` provider is registered in `LlmModule`, documented in `docs/langchain.md`, counted among the supported providers, and offered by the agent wizard, the setup API, `McpSetupTools` and `CreateSubAgentTool`. It was also, in every JVM this repository starts, running Jlama's pure-scalar tensor backend.

### Why it was invisible

Jlama picks its backend once, at first use, in `TensorOperationsProvider.pickFastestImplementation()`:

1. it tries `NativeSimdTensorOperations`, which needs `com.github.tjake:jlama-native` — not on our classpath — logs "Native operations not available" and moves on;
2. it falls back to `MachineSpec.VECTOR_TYPE`, which reads `FloatVector.SPECIES_PREFERRED` inside a `catch (Throwable)`. Without `--add-modules=jdk.incubator.vector` that throws, the catch logs one line through Jlama's own logger, and the type stays `NONE`;
3. `NONE` selects `NaiveTensorOperations` — scalar Java matrix arithmetic.

So a Jlama agent *worked*. It answered correctly, orders of magnitude too slowly to use, and nothing in the build could see it: no test configured a Jlama agent, and the container ITs exercise the image over HTTP.

### What changed

* **`src/main/docker/Dockerfile`** — the flag on a new `ENV JDK_JAVA_OPTIONS`, **not** on `JAVA_OPTS_APPEND` where EDDI's other JVM settings live. The launcher reads `JDK_JAVA_OPTIONS` itself, so the flag survives an operator overriding `JAVA_OPTS` or `JAVA_OPTS_APPEND` — and a `docker run -e JAVA_OPTS_APPEND=...` *replaces* the image's value rather than appending to it. `docs/setup-eddi-on-aws-with-mongodb-atlas.md` does exactly that to pass a MongoDB connection string, so the obvious placement would have been silently dropped by a deployment shape we document ourselves.
* **`src/main/docker/Dockerfile.demo`** — the flag in the `ENTRYPOINT` array. The demo image starts EDDI with a bare `java` command, so it has neither `run-java.sh` nor any ENV to inherit from.
* **`pom.xml`** — the flag on the Surefire fork's `argLine`, and deliberately **not** on the Failsafe fork's. Failsafe's `argLine` *parameter* defaults to the `${argLine}` property, which both `jacoco:prepare-agent-integration` and the Quarkus Maven extension populate; declaring an explicit element there replaces the lot, dropping the JaCoCo IT agent that feeds the merged 90/80 coverage gate, the add-opens Quarkus injects, and the serialized app-model path. An earlier revision of this branch did exactly that. ITs exercise the image over HTTP and never build a Jlama model in the test JVM, so the flag buys nothing there.
* **`mise.toml`** — the flag via `-Djvm.args` on **both** tasks that fork a dev JVM, `dev` and `debug`. `debug` was missed initially and the test could not see it, because it matched only the first `quarkus:dev` line.
* **`JlamaRuntimeSupport`** (new) — reads `MachineSpec.VECTOR_TYPE`, the field Jlama's own `TensorOperationsProvider` branches on, and logs one warning that distinguishes the two causes: a missing flag (fixable in one line) versus a CPU exposing no vector species Jlama accepts (not fixable by any flag). Warns rather than fails — a degraded Jlama still answers correctly, and failing closed would turn a slow deployment into a broken one on upgrade, for a condition the operator may not be able to fix.
* **`JlamaLanguageModelBuilder`** — exposes four of the five settings Jlama accepts and EDDI was dropping: `modelCachePath`, `quantizeModelAtRuntime`, `workingDirectory`, `workingQuantizedType`. Booleans go through `ModelParameterValues.applyBoolean` rather than `Boolean.parseBoolean`, so `"ture"` leaves the provider default instead of silently meaning `false`; `workingQuantizedType` resolves against Jlama's `DType` enum with an unknown name logged and ignored rather than thrown on every turn. The parameter mapping moved into a package-visible `applyTo` that stops short of `build()`.
* **`threadCount` is deliberately NOT exposed**, though Jlama's builder accepts it and an earlier revision of this branch mapped it. `JlamaModel.Loader` hands the value to `ModelSupport.loadModel`, which calls the process-global `PhysicalCoreExecutor.overrideThreadCount` — a one-shot latch (`if (!started.compareAndSet(false, true)) throw new IllegalStateException(...)`) that the executor's memoized `instance` supplier also arms merely by running inference. A per-model parameter cannot honour a process-global one-shot setting: the second Jlama model built in a process would throw `"Executor already started"` during load, even with an identical value. And `ChatModelRegistry` rebuilds models on cache eviction, secret rotation and a 30-minute idle TTL, so a second build is routine rather than exotic. Caught in review; pinned by a test, because the setter sits on the builder right next to the mapped ones and re-adding it looks like an obvious omission being corrected.

`modelCachePath` is the one that matters operationally, though not for the reason that first looked obvious. Jlama caches weights under `${user.home}/.jlama/models`, and in the EDDI image that path *is* writable — including for an arbitrary OpenShift UID, because the base image sets `HOME` and the JDK falls back to it. The real problem is that it resolves to the pod's ephemeral writable layer: the multi-gigabyte weights live exactly as long as the pod, so every restart re-downloads them from Hugging Face before the first turn can be answered, and an air-gapped deployment cannot start at all.

### Design decisions

* **No `jlama-native`.** It would put platform-specific, glibc-sensitive shared objects into a digest-pinned UBI image, add Trivy scan surface and require `--enable-native-access`. The Vector API path is pure Java and gets most of the benefit.
* **No live-inference smoke test.** It would download gigabytes from Hugging Face on every CI run. `JlamaRuntimeSupportTest#simdBackendIsAvailableInThisJvm` asserts that *Jlama's own* backend selection finds a vector type in the test JVM instead — which is the thing that was silently false, and it costs microseconds.
* **Probe Jlama's decision, not the module.** An earlier revision asked whether `jdk.incubator.vector` was resolved. That is a strictly weaker question: `MachineSpec` accepts only a 512- or 256-bit preferred species (or 128-bit on ARM), so on an x86 host exposing a 128-bit species — a hypervisor masking AVX2, or `-XX:UseAVX=0` — the module resolves, the class loads, and Jlama still picks the scalar backend with no warning. Reading `MachineSpec.VECTOR_TYPE` cannot diverge from what Jlama actually does.
* **`timeout` stays a pipeline key.** `JlamaChatModel.builder()` has no timeout setter; the value is applied by `ObservableChatModel` as a wall-clock bound. Both tempting "fixes" — adding it to `recognisedParameters()`, or deleting it from the documented example — would be wrong, so `JlamaTests#timeoutIsNotABuilderParameter` pins the current behaviour.

### Verified against the real base image

Rather than reasoning about whether `run-java.sh` forwards a non-`-D` flag, this was run inside `ubi10/openjdk-25-runtime` at the exact digest the Dockerfile pins:

* without the flag: `moduleResolved=false classLoadable=false` — the bug, reproduced in the published base image;
* with it: `moduleResolved=true classLoadable=true`, plus the expected `WARNING: Using incubator modules` line;
* with it on `JDK_JAVA_OPTIONS` and `JAVA_OPTS` *or* `JAVA_OPTS_APPEND` overridden by the operator: still `true`. That is why the flag lives there and not on `JAVA_OPTS_APPEND`.

### Tests

`JlamaRuntimeFlagsTest` (5) greps both Dockerfiles, every `<argLine>` in `pom.xml` and every mise task that forks a dev JVM, so losing the flag from any of the five places it has to appear fails the build where the change was made. It also asserts the flag is on *exactly one* `<argLine>`, so re-adding it to failsafe fails here rather than quietly costing the IT coverage data. `JlamaRuntimeSupportTest` (10) proves the SIMD backend is genuinely selected in a running JVM — the two are complementary: a typo fails both, a flag written into a config block Maven never applies fails only the second. `LanguageModelBuildersTest.JlamaTests` (10) covers the parameter mapping, including that every key in `recognisedParameters()` actually changes builder state, that a mistyped boolean does not silently mean `false`, that an unknown `DType` name is ignored rather than thrown, that `threadCount` stays unmapped, and that the Hugging Face token stays masked.

### Docs

`docs/langchain.md`'s Jlama section rewritten: a full parameter table, the required JVM flag and why its absence is silent, and the three container behaviours that differ without erroring — `modelCachePath` landing on the ephemeral layer, inference threads being sized from a CPU *limit* but not a *request* (cgroup shares have been ignored since JDK 19), and memory sizing for memory-mapped safetensors that count against the container limit but not the heap. Plus the air-gap caveat that an empty cache with no egress fails rather than degrades.

Assessment behind this work: [`planning/inference-stack-fit.md`](https://github.com/labsai/EDDI/tree/main/planning/inference-stack-fit.md). It also carries a correction — an earlier draft wrongly called `timeout` an unrecognised parameter.

***

## 🐛 fix(ui): the agent wizard could only produce a Jlama agent that never loads (2026-09-20)

**Repo:** EDDI (`fix/manager-jlama-wizard`)

Every Jlama path through the Manager's agent wizard produced an agent that failed on its first message. Three defects, all of the same shape — the wizard offering a value the backend cannot use, and reporting success anyway.

### What was wrong

1. **The model suggestions were not resolvable.** `MODEL_SUGGESTIONS.jlama` offered `llama-3.2-1b` and `tinyllama`. Jlama resolves a model through `JlamaModelRegistry`, which downloads it from Hugging Face and wants an `owner/name` repository id. A bare name has no owner to look up, so the download fails on the agent's first turn — long after the wizard said "created". `LLM_PROVIDERS`'s `defaultModel` carried the same bare name, which is what the user sees as the model placeholder before typing.
2. **A `baseUrl` field for a provider with no endpoint.** The wizard offered Jlama a Base URL with a `http://localhost:8080` placeholder. Jlama runs *in-process* inside the EDDI JVM; `AgentSetupService` drops `baseUrl` for `jlama` before the builder is reached, and `JlamaLanguageModelBuilder.recognisedParameters()` does not contain it either. Anything typed there vanished with no visible trace.
3. **…and the field was marked required.** `isBaseUrlRequired` returned true for `jlama`, which in `operator-activation.tsx` is a hard gate (`modelStepValid`): an admin activating the Platform Operator on Jlama could not proceed without filling in a field whose value was then thrown away.
4. **A hidden field still submitted its stale value.** A consequence of fixing (2): with the field no longer rendered, a base URL typed for a previous provider stayed in wizard state and was still sent, with no way for the user to see or clear it. `handleProviderChange` now clears it — in the wizard and in operator activation — when the incoming provider has no endpoint.

Worth noting that the *rule-based* reference config in `docs/agent-configs/` already had this right — its Jlama chooser offers `tjake/TinyLlama-1.1B-Chat-v1.0-Jlama-Q4`. The Manager wizard was the outlier.

### What changed

**Manager (`ui/manager`)**

* `src/lib/model-suggestions.ts` — Jlama now suggests only ids this repository already treats as real: `tjake/Llama-3.2-1B-Instruct-JQ4` (from `docs/langchain.md`) and `tjake/TinyLlama-1.1B-Chat-v1.0-Jlama-Q4` (from the shipped reference config). Any other `owner/name` repo can still be typed.
* `src/lib/model-suggestions.ts` — new `supportsBaseUrl()` backed by an `IN_PROCESS_PROVIDERS` set; `isBaseUrlRequired()` no longer returns true for `jlama`.
* `src/pages/agent-wizard.tsx` — the Base URL block is omitted entirely when `supportsBaseUrl` is false, and a Jlama note replaces it: no endpoint, weights come from Hugging Face on first use, and the tuning parameters worth setting afterwards. The model hint becomes Jlama-specific (the `owner/name` requirement).
* `src/lib/api/agent-setup.ts` — `defaultModel` is a real repo id; the provider label is "Jlama (In-Process)" rather than "Jlama (Local)", which read like Ollama.
* Four new i18n keys across all 11 locales.

**Backend**

The deployment parameters the wizard now points users at did not exist yet — surfacing them without adding them would have been a fresh instance of the same bug. `JlamaLanguageModelBuilder` now reads `modelCachePath`, `quantizeModelAtRuntime`, `workingDirectory` and `workingQuantizedType`, all of which `JlamaChatModel.builder()` has always accepted. `modelCachePath` is the one that matters operationally: its default is `~/.jlama/models`, which in a container is the ephemeral writable layer, so every pod restart re-downloads multiple gigabytes.

**Merge note (main):** this branch originally also mapped `threadCount`. While this branch was in flight, `fix/jlama-vector-api` landed on `main` and deliberately removed `threadCount` from `recognisedParameters()` — it reaches Jlama's process-global, one-shot `PhysicalCoreExecutor.overrideThreadCount`, which throws on any second call, so it cannot be a safe per-model setting (`ChatModelRegistry` rebuilds Jlama models on cache eviction, secret rotation and idle TTL). Merging this branch with `main` kept `main`'s exclusion rather than reintroducing `threadCount`; the wizard-facing fixes below are unaffected, since the wizard never exposed `threadCount` itself. `main` also factored the parameter mapping into a static `applyTo` for testability and added the `JlamaRuntimeSupport.warnOnceIfDegraded()` call — both preserved as-is by the merge.

`ModelParameterValues` gains `applyPath`, following the existing lenient-read convention — an unusable value is logged and skipped so the model default stands, rather than throwing out of the build path and failing every conversation the agent serves. `main`'s version of `JlamaLanguageModelBuilder` had applied `modelCachePath` and `workingDirectory` inline instead (an `isNullOrEmpty` check plus a bare `Path.of`), which meant a NUL-containing or otherwise unusable configured path threw `InvalidPathException` during model construction instead of leaving Jlama's default in place — the same failure mode `applyPath` exists to prevent. Review feedback on this PR caught the mismatch after the merge, so `applyTo` now routes both settings through `applyPath`, and `LanguageModelBuildersTest` gained a case asserting that an unusable path for either setting falls back to the default rather than propagating.

**Review round 3 (2026-09-24):** two more CodeRabbit findings, both minor.

* `ModelParameterValues.applyPath`'s rejection warning logged the sanitized-but-not-redacted configured path (`sanitize(raw)` only strips control characters — see `src/main/java/ai/labs/eddi/utils/LogSanitizer.java` — it does not remove the printable path itself). An invalid `modelCachePath` or `workingDirectory` could therefore place a username's home directory or an internal project path into the warning log (CWE-532). The log line now names the rejected parameter key only and omits the value; the other `ModelParameterValues` warnings (`applyBoolean`, `booleanValue`, `rejected`) and `JlamaLanguageModelBuilder.applyWorkingQuantizedType` were swept for the same pattern — none of them carry filesystem paths, so they were left as-is.
* The `jlamaNoteTuning` copy claimed the container-cached model is "re-downloaded on every restart." A container restart (`docker restart`, a crash restart) preserves its writable layer, so the cache actually survives a restart; it is lost only when the container is removed or replaced (a redeploy, `docker rm`, a Kubernetes pod recreation). Reworded the English fallback in `agent-wizard.tsx` and all 11 locale translations to say the model "may be re-downloaded after the container is removed or replaced."

### Design decisions

* **Omit the field rather than disable it.** A disabled or ignored Base URL input still tells the reader an endpoint exists. For an in-process provider it does not, so the field is not rendered at all — the same reasoning the group-collaboration configs use for tools that are never assembled (root `AGENTS.md` §4.2).
* **Only ship model ids the repo already vouches for.** A longer catalogue would have meant guessing at Hugging Face repo names, which is exactly the failure being fixed. The hint text carries the `owner/name` rule so a user can supply their own.
* **`workingQuantizedType` is parsed case-insensitively** and an unknown value keeps Jlama's default. `q4` is what a user writes; `Q4` is what the enum calls it.
* **`jlama-core` stays undeclared in `pom.xml`.** `DType` is imported from it transitively via `langchain4j-jlama`; pinning a second version of a library the langchain4j artifact already manages would be the worse hazard.

### Files

* `src/main/java/ai/labs/eddi/modules/llm/impl/builder/JlamaLanguageModelBuilder.java`
* `src/main/java/ai/labs/eddi/modules/llm/impl/builder/ModelParameterValues.java`
* `src/test/java/ai/labs/eddi/modules/llm/impl/builder/LanguageModelBuildersTest.java`
* `src/test/java/ai/labs/eddi/modules/llm/impl/builder/ModelParameterValuesTest.java`
* `docs/langchain.md` — the Jlama section now states the `owner/name` requirement, that there is no `baseUrl`, and documents the five parameters in a table
* `ui/manager/src/lib/model-suggestions.ts`
* `ui/manager/src/lib/api/agent-setup.ts`
* `ui/manager/src/lib/api/operator.ts` — stale "(Ollama, Jlama)" doc comment
* `ui/manager/src/components/operator/operator-activation.tsx`
* `ui/manager/src/pages/agent-wizard.tsx`
* `ui/manager/src/lib/__tests__/model-suggestions.test.ts` (new)
* `ui/manager/src/pages/__tests__/agent-wizard.test.tsx`
* `ui/manager/src/i18n/locales/*.json` (11 files)

### Verification

* `./mvnw compile` clean; `LanguageModelBuildersTest` + `ModelParameterValuesTest` — the new Jlama and `applyPath` cases pass (the pre-existing failures in that class are the sandbox's "Unable to establish loopback connection", not this change).
* Repo-wide guards green: `ImportStyleTest`, `DocumentationLinksTest`, `StrictBoundaryShippedConfigsTest`, `RuleSetStoreShippedRulesetsTest`, `ChangelogRotationTest`, `BuildQualityGatesTest`.
* Round 3: `.\mvnw.cmd compile` (Checkstyle + `formatter:validate` clean); `.\mvnw.cmd test "-Dtest=LanguageModelBuildersTest,ModelParameterValuesTest,JlamaRuntimeSupportTest,ChangelogFragmentTest"` — all Jlama- and `applyPath`-specific cases pass (same pre-existing loopback-socket failures as above, unrelated to this change).
* Manager: `npm run typecheck`, `npm run lint`, `npm run i18n:check`, `npm run build` and the full suite (413 files, 6556 tests) all pass.
* **The six new UI tests were mutation-checked.** Reverting the suggestions, the default model or `IN_PROCESS_PROVIDERS` fails the five that pin the wizard's Jlama behaviour; dropping the `handleProviderChange` clear fails the sixth. They would have caught this.

***

## 📝 docs(mcp): how to reach an authenticated `/mcp`, and the plan to stop needing this (2026-09-20)

**Repo:** EDDI (`docs/mcp-oauth-plan`)

A local MCP client — Claude Desktop, Claude Code, Cursor, LM Studio — cannot practically manage an EDDI instance that has OIDC enabled. `/mcp` carries an `authenticated` policy (its own `quarkus.http.auth.permission.mcp` rule), EDDI is bearer-only (`application-type=service`), and it advertises no OAuth metadata, so a client that would log in by itself gets a bare 401 with nothing to discover. The only way in is a hand-pasted token that the shipped realm lets expire after Keycloak's default five minutes, and there is no long-lived key for `/mcp` (the only api-key surface is the `/v1` adapter).

The Quick Start in `docs/mcp-server.md` only ever showed the unauthenticated `localhost:7070` case, so nothing said any of that.

### What changed

* **`docs/mcp-server.md`** — new *Connecting to an authenticated instance* section under Authentication & Authorization: get a token from the public `eddi-frontend` client, pass it either as a header on a Streamable-HTTP client or through `mcp-remote` (whose argument splitting means the value belongs in an env var), and four caveats in the order they bite — expiry, no api key, roles decide which tools work, and `/mcp` cannot be opened selectively. The Quick Start now points at it.
* **`docs/mcp-server.md`** — the Configuration block documented `quarkus.mcp-server.http.root-path`. That hyphenated form is not a key the extension knows; `application.properties` already says so. Corrected to `quarkus.mcp.server.http.root-path` with the warning kept.
* **`planning/mcp-oauth-protected-resource-plan.md`** (new) — the fix: advertise `/mcp` as an RFC 9728 protected resource so the client runs the OAuth flow and refreshes its own token, removing the shared long-lived credential rather than automating its rotation.

### Decisions

* **Rotation is the wrong problem to solve.** The instinct is to reuse **Connections**, which already does lazy OAuth refresh with a single-flight claim. It cannot apply: a connection resolves to a header on a request *EDDI originates*, and here EDDI is the callee. Connections exists because EDDI holds a credential it must refresh; inbound, the client holds it.
* **Serving the metadata is configuration, not code.** Quarkus OIDC 3.39.3 already ships `ResourceMetadataHandler` and appends `resource_metadata="…"` to the 401 challenge. The plan's Increment 1 is four properties, a permit rule and a Keycloak client.
* **A permit rule is mandatory, not a precaution.** That handler registers as `FilterBuildItem(handler, 50)`, and `SecurityHandlerPriorities.AUTHORIZATION` is 100 — it runs *after* authorization, so the catch-all at `/*` would 401 the discovery document and the flow could never start.
* **Pre-registered client over dynamic registration.** The realm defines no `roles` client scope; `eddi-frontend` gets `realm_access.roles` only from its own protocol mapper. A dynamically registered client cannot carry mappers, so its tokens authenticate and then fail every tool with "requires role" — the worst failure shape available.
* **Review follow-up (2026-09-21).** The §3.2 configuration block quoted a hardcoded `/mcp` in both `resource-metadata.resource` and the permit rule's second path. What ships derives both from `${quarkus.mcp.server.http.root-path}`, so an operator who moves the MCP root moves the metadata document and its permit rule with it; a hardcoded permit path would leave the relocated document behind the `authenticated` policy and 401 the discovery request before it starts. The snippet now matches `application.properties`.
* **The EDDI → client direction is deliberately out of scope** and recorded as such in the plan, so it is not re-derived: it needs Claude Code channels rather than MCP, and two design answers first — attribution (nothing reads the token's `azp`, so a model answering a HUMAN member's turn is recorded as the person) and keeping HITL decisions out of an AI client's reach.

### Files

* `docs/mcp-server.md`
* `planning/mcp-oauth-protected-resource-plan.md` (new)

## 🔑 feat(keycloak): ship an `eddi-mcp` client for MCP clients to log in through (2026-09-20)

**Repo:** EDDI (`feat/keycloak-mcp-client`, stacked on `feat/mcp-oauth-discovery`)

Increment 1 of [`planning/mcp-oauth-protected-resource-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/mcp-oauth-protected-resource-plan.md), second half. The previous entry made EDDI tell a client *where* to authenticate; this gives it something to authenticate as.

### What changed

* **All three realm copies** (`keycloak/`, `helm/eddi/files/`, `k8s/overlays/auth/`) gain `eddi-mcp`: public, authorization code + PKCE `S256` required, direct access grant / implicit / service accounts all off, redirect URIs `http://localhost:*` and `http://127.0.0.1:*`, no web origins, and the `realm-roles`, `eddi-backend-audience` and `groups` protocol mappers copied from `eddi-frontend`.
* **`DeploymentManifestsTest`** — a case per realm copy asserting the flow settings, the PKCE requirement, the mappers (by claim name and by *access* token, not just id token), that no redirect is `*` or a remote http URL, that `webOrigins` is empty, and that neither `name` nor `description` exceeds 255 characters: Keycloak stores them in `VARCHAR(255)` and an over-long value does not truncate — **the realm import fails and Keycloak exits 1**, which is how the first draft of this client took down every stack that imports the realm. Found by running the import, not by reading the file.
* **`helm/eddi/templates/NOTES.txt`**, **`k8s/overlays/auth/kustomization.yaml`**, **`docs/security.md`** — every place that told an operator to grant an account "those two roles" now names all three. Following the old instruction built an administrator that logs in and is refused every MCP read tool, which is the trap the realm change exists to close.
* **`.github/workflows/ci.yml`** — `keycloak/**` added to the `code` and `backend` path filters. `k8s/` and `helm/` were already there, so the compose realm was the one copy whose change ran no CI — including the audience mapper every accepted token depends on.
* **`docs/mcp-server.md`**, **`docs/security.md`** — the client, how to point a client at it, why dynamic registration is not an option here, and what to do on an **existing** realm: `--import-realm` never re-imports into a realm that already exists and both auth stacks keep Keycloak's database in a named volume, so an upgrade leaves the client absent and the flow ends in `invalid_client`. The manual steps are listed, `realm-roles` first.
* **All three realm copies** — the seeded `eddi` administrator gains `eddi-viewer` alongside `eddi-admin`/`eddi-editor`. There is no role hierarchy, so without it the account an operator points their first MCP client at completes the login and is then refused all 27 viewer-gated tools. A test pins it. `scripts/make-test-realm.mjs` guards that fixture set against the realm and fails the auth E2E run when the two drift, so `ROLE_FIXTURES` and `e2e/auth/auth-helpers.ts` move with it — which is how CI caught this change the first time it ran.

### Decisions

* **Pre-registered client, not dynamic registration.** Not a preference: this realm supplies its own `clientScopes` and defines no `roles` scope, so `realm_access.roles` comes only from a client's own protocol mapper. RFC 7591 registration carries no mappers, so a self-registered client would mint tokens that authenticate and then fail every tool with "requires role" — login succeeded, everything forbidden. Keycloak's default registration policies would also have to be loosened in at least three places to get there.
* **Loopback redirects only; `https://claude.ai/api/mcp/auth_callback` is not shipped.** Claude Desktop connectors redirect to that remote callback, so the authorization response for a self-hosted EDDI would pass through a third party. That is an operator's decision, documented in `docs/mcp-server.md`, rather than a default inherited from us.
* **No `webOrigins`, not even `+`.** These clients are native processes; `eddi-frontend` needs browser origins and this one never makes a browser request.
* **The redirect list is the one `[ext]` assumption in the plan.** Which loopback path each client uses is documented client behaviour rather than something verified here, so the entries are the broad `localhost` / `127.0.0.1` wildcards the realm already uses for the SPA, and the docs say to add anything else in the admin console.

### Files

* `keycloak/eddi-realm.json`, `helm/eddi/files/eddi-realm.json`, `k8s/overlays/auth/eddi-realm.json`
* `src/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.java`
* `docs/mcp-server.md`, `docs/security.md`

***

## 🔐 feat(mcp): advertise `/mcp` as an OAuth protected resource, so clients sign themselves in (2026-09-20)

**Repo:** EDDI (`feat/mcp-oauth-discovery`, stacked on `docs/mcp-oauth-plan`)

Increment 1 of [`planning/mcp-oauth-protected-resource-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/mcp-oauth-protected-resource-plan.md). An MCP client now discovers where to authenticate and holds its own token, instead of an operator pasting a bearer that expires in five minutes.

Quarkus OIDC 3.39.3 already serves the RFC 9728 document and appends `resource_metadata="…"` to the 401 challenge, so there is no new EDDI code — five properties and one permit rule.

### What changed

* **`application.properties`** — `quarkus.oidc.resource-metadata.*`: `enabled` tracks `tenant-enabled` (an instance with auth off has no authorization server to name, and the handler is not installed for a disabled tenant), `resource=/mcp`, `force-https-scheme=true`, `scopes=openid`, and `authorization-server` preferring `token.issuer` over `auth-server-url`.
* **`application.properties`** — a `permit` rule for the two exact metadata paths (the bare form and the path-inserted document), `GET,HEAD` only. Exact rather than a `/*` under the prefix, which would anonymously expose any future handler beneath it.
* **`helm/eddi`** — `eddi.oidc.resourceMetadata.{forceHttpsScheme,authorizationServer}`, because neither is safely inferable: `publicUrl` describes Keycloak, not EDDI, so an https IdP in front of a plain-http port-forward would advertise a resource nothing serves. The scheme now follows EDDI's own `ingress.tls` unless set. Chart version bumped per Chart.yaml's rule.
* **`application.properties`** — the MCP security banner said 33 tools (there are 84) and described a two-role model (there are four, with no hierarchy).
* **`McpOAuthDiscoveryConfigTest`** (new, 8 cases) — the config *is* the feature, so it is what gets asserted: the enabled expression, the resource matching the MCP root path, the issuer preference, `openid` while `user-info-required` is on, the permit rule's policy/methods/paths, what those paths match and do not match, and `/mcp` still being `authenticated`.
* **`ui/manager/e2e/auth/auth.spec.ts`** — two cases in the Keycloak tier: the document is readable with no token and names the issuer a real token carries; an unauthenticated `/mcp` POST answers 401 with a challenge pointing at it.
* **Every shipped stack that serves plain http with authentication on** overrides `force-https-scheme`, because that is the one shape a forced https identifier is wrong for: `docker-compose.auth.yml`, the auth E2E tier, `k8s/overlays/auth` (its documented flow is `kubectl port-forward`), and the helm chart whenever the Keycloak URL it is given is itself plain http. Without it those deployments advertise `https://…/mcp` with nothing serving TLS, and discovery dies before it starts.
* **`ui/manager/docker-compose.integration-keycloak.yml`** — the tier now pins the browser-reachable issuer the way `docker-compose.auth.yml` does, so the discovery document it publishes is the one a real deployment publishes. The E2E case can therefore assert the advertised authorization server is reachable **from outside the compose network** — with the old in-cluster hostname that assertion could not have failed.
* **`docs/mcp-server.md`**, **`docs/security.md`** — the discovery path as the preferred way in, with the hand-pasted token demoted to a fallback; the new permit row, and why `@PermitAll` alone does not make a path public.

### Decisions

* **The permit rule is mandatory, not defence in depth.** quarkus-oidc registers its handler as `FilterBuildItem(handler, 50)` and `SecurityHandlerPriorities.AUTHORIZATION` is 100 — higher runs first, so authorization would answer 401 before the document could be read, and discovery could never start. Verified by `javap` on `OidcBuildStep`, and asserted over HTTP in the auth E2E tier because no properties file can prove an ordering.
* **Exact paths, never `/.well-known/*`.** A wildcard there would pre-permit whatever lands under that prefix later. A test asserts what the patterns match *and* what they must not.
* **`authorization-server` defaults to `token.issuer`.** `auth-server-url` is the cluster-internal Keycloak address in both shipped deployments, so the default would have advertised a host no client outside the cluster can resolve. RFC 8414 wants the advertised server to equal the issuer regardless, and the E2E assertion compares it against the `iss` claim of an accepted token rather than a hardcoded URL.
* **`openid` is advertised deliberately.** `user-info-required=true` makes EDDI call userinfo on every request, and Keycloak refuses userinfo for a token minted without that scope; clients copy `scopes_supported` into the authorize request. A test pins the pair together so removing one surfaces the other.
* **https is forced by default.** `quarkus.http.proxy.*` is unset, so behind a TLS-terminating ingress the identifier would be advertised as `http://`.
* **The permit path interpolates the MCP root path (review round 4).** It read `/.well-known/oauth-protected-resource/mcp`, a literal, while the advertised resource was already derived from `${quarkus.mcp.server.http.root-path}`. An operator who moved the MCP root would have moved the document with it and left the permit rule behind: the metadata request then meets the catch-all `authenticated` policy and answers 401, and the 401 challenge that is supposed to bootstrap discovery points at a path that also answers 401. Both halves now interpolate the same property. `McpOAuthDiscoveryConfigTest` asserts the raw expression — asserting the resolved form would pass either way — and resolves it against the root path for its match checks; reverting the property to the literal fails that test. `A2aEndpointPermissionsTest` builds its matcher from this file, so it grew a small expander for `${…}` inside a permission path and now probes the path-inserted document through it; unexpanded, that path entered the matcher as a literal and the model reported `authenticated` for a document Quarkus permits.

### Files

* `src/main/resources/application.properties`
* `src/test/java/ai/labs/eddi/configs/McpOAuthDiscoveryConfigTest.java` (new)
* `ui/manager/e2e/auth/auth.spec.ts`, `ui/manager/docker-compose.integration-keycloak.yml`
* `docs/mcp-server.md`, `docs/security.md`
* `src/test/java/ai/labs/eddi/engine/a2a/A2aEndpointPermissionsTest.java`

## 🛡️ fix(security): validate the token audience, and stop calling userinfo on every request (2026-09-20)

**Repo:** EDDI (`feat/oidc-audience-validation`, stacked on `feat/keycloak-mcp-client`)

Increment 2 of [`planning/mcp-oauth-protected-resource-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/mcp-oauth-protected-resource-plan.md), kept separate because it changes which tokens are accepted — every token, not only MCP ones.

Quarkus verifies `aud` on an **access** token only when `quarkus.oidc.token.audience` is set (`OidcIdentityProvider` passes `enforceAudienceVerification = idToken`, and with the property unset `OidcProvider` calls `setSkipDefaultAudienceValidation()`). It was unset, so EDDI accepted any token the realm issued for any client in it — and roles come from `realm_access/roles`, which is client-independent, so such a token arrived carrying the user's full rights. The `eddi-backend-audience` mapper that has been on `eddi-frontend` all along is evidence someone intended this check and never switched it on. It matters more now that a second client exists.

### What changed

* **`application.properties`** — `quarkus.oidc.token.audience=eddi-backend`.
* **`application.properties`** — `quarkus.oidc.token-cache.{max-size=1000,time-to-live=3M,clean-up-timer-interval=5M}`. `user-info-required=true` means one Keycloak round trip per request, which an MCP client makes many of. Signature, expiry and audience are still checked per request, before the cache is consulted at all. What it defers is the userinfo call, which doubles as a session-revocation check (Keycloak refuses userinfo for a logged-out session) — so a killed session keeps working for up to the TTL. That, not token expiry, is why the TTL is short.
* **`DeploymentManifestsTest`** — every client that can mint a token (standard, direct-grant, implicit or service-account flow) must mint the audience the property requires, in all three realm copies, compared against the property rather than a spelling repeated in the test.
* **`ui/manager/e2e/auth/mcp-oauth.spec.ts`** (new) — the middle of the feature, which the discovery and 401 cases do not reach: authorization code + PKCE against `eddi-mcp`, the token exchange, then `/mcp` `initialize` and a real `list_agents` call. It asserts the token carries `aud=eddi-backend` and realm roles, so the two silent failures — a token the backend refuses, and one that authenticates and is then refused by every tool — surface as themselves rather than as a generic 401.
* **`docs/security.md`**, **`docs/open-webui-integration.md`** — both properties in the table, what a hand-built realm has to do, and the `/v1` adapter's 401-under-OIDC entry, which now also means "and carrying `aud=eddi-backend`".

### Compatibility

**This rejects tokens that were accepted before.** A deployment whose users authenticate through a client *without* an audience mapper starts answering 401. The shipped realm is unaffected (both login clients carry the mapper). The fix for a custom realm is to add the mapper; the escape hatch is `QUARKUS_OIDC_TOKEN_AUDIENCE=any` — quarkus-oidc's own sentinel for skipping audience validation (`OidcProvider.ANY_AUDIENCE`), which restores the old behaviour.

### Files

* `src/main/resources/application.properties`
* `src/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.java`
* `docs/security.md`, `docs/open-webui-integration.md`

## 🔒 fix(security): sanitize the agent-deployment log lines CodeQL flagged for CWE-117 (2026-09-20)

**Repo:** EDDI (`fix/log-injection-agent-deployment-logs`)

GitHub code scanning had 14 open `java/log-injection` alerts against `refs/heads/main` in two files — 9 in `RestAgentAdministration` (#90–#92, #97–#102) and 5 in `AgentFactory` (#116–#120). They are not new: they surfaced while triaging community PRs #558 and #561, which only rename logger fields and neither introduce nor fix any of them. `agentId` is a path parameter on every endpoint involved, so a CR/LF in one closes the real log record and lets the remainder read as a second line the server wrote itself — a forged `[SCHEDULE] Auto-enabled` line for an agent nobody deployed, for instance.

### What changed

* **`AgentFactory`** — `waitForDeploymentCompletion` quoted the raw `AgentId` on five lines (two "did not complete successfully" ERRORs, the still-deploying DEBUG, the timeout WARN and the interrupted WARN). It now derives one `var safeAgentId = sanitize(agentIdObj.toString())` after the listener lookup and every line quotes that — one call site instead of five, and the rendering is byte-identical to the old `%s` on the object for a benign id. `logAgentDeployment`'s two INFO lines (alerts #119/#120, the only two still current at `798c6e84`) now `sanitize(agentId)`. The file already sanitized its three `debugf` calls; these were simply missed.
* **`RestAgentAdministration`** — all 9 flagged sinks: the deploy-wait timeout WARN, the deploy-failed WARN (both the id *and* the cause's message), the successful-undeploy INFO, `throwError` and `throwErrorForbidden`, and the four `[SCHEDULE]` auto-enable/auto-disable lines, which also quote `schedule.getName()` and `schedule.getId()` verbatim. Log levels and message wording are unchanged throughout.
* **`RestAgentAdministration` now static-imports `sanitize`** rather than calling `LogSanitizer.sanitize(...)` on two pre-existing sites, matching `RestAgentStore` and `RestWorkflowStore` and AGENTS.md's ban on inline qualification. The class import is gone, so Checkstyle's `UnusedImports` stays green.

### Tests

Two new classes in the established shape — `captureLogsOf(<Class>.class, …)` plus `assertNoForgedRecordBoundary(...)` against `LogCaptureSupport.FORGED_RECORD`, attaching by logger category rather than by field name (so PR #558's renames cannot break them):

* `AgentFactoryLogInjectionTest` — 6 tests. Reaching `waitForDeploymentCompletion` needs a published `IN_PROGRESS` placeholder, so each test parks a deployment inside its store lookup on a virtual thread (the idiom `AgentFactoryUndeployVersionTest` already uses) and calls `getAgent` from the test thread.
* `RestAgentAdministrationLogInjectionTest` — 9 tests, one per alert.

Every one of the 17 arguments newly wrapped in `sanitize(...)` was mutation-checked one at a time: revert exactly that call, run the class, require the named test to fail. 17 mutations, 17 killed, 0 survived. Driver lived in the session scratchpad and is not committed.

### Decisions

* **Which alerts are real.** Three of the five `AgentFactory` alerts (#116–#118) were last seen at `d5294a60` and point at lines that carry `sanitize(agentId)` on today's `main` — stale instances that should close on the next scan of the branch. The genuinely unsanitized sinks in that file were the two `logAgentDeployment` INFOs plus the five `waitForDeploymentCompletion` calls, which CodeQL's stale line refs no longer name. Fixing by *call site* rather than by *reported line* is the only way to end up with the file actually clean.
* **`throwErrorForbidden`'s sanitize also reaches the client.** Its `message` is both logged and put into the `WebApplicationException` body, so sanitizing the id narrows a response-splitting surface as well. That is a strict improvement and the wording is untouched, so it stays on one call.
* **The auto-enable/auto-disable failure WARNs pin the id only.** Both pass the cause as the record's *throwable*, not as a format parameter, so `LogCaptureSupport` never sees its message — those tests deliberately use a benign exception message rather than implying coverage they do not have.
* **The `CompletableFuture` in the timeout/interrupt tests is hand-written, not mocked.** The default mock maker does not intercept `CompletableFuture.get(long, TimeUnit)`: a stubbed mock silently ran the real 60-second wait, and Mockito reported it as `UnfinishedStubbing` from inside the JDK. A four-line anonymous subclass whose `get` throws is deterministic and needs no mock maker at all.
* **Each capture window holds exactly one line under test.** The parked deployment's own `logAgentDeployment` INFO fires *before* the store lookup and the endpoint calls run outside the window, with only the captured Callable inside it. Without that ordering a test could be satisfied by whichever line leaked first, and the per-site mutation check would not localise.

### Still open

`java/log-injection` has \~40 further open alerts on `main` in `GroupHitlCoordinator`, `GroupConversationService`, `MemberTurnExecutor`, `ConversationHitlService`, `PhaseExecutionEngine`, `AuditLedgerService` and others. Out of scope here; same one-line fix and same test shape apply.

## 🔒 fix(apicalls): auto-vaulted properties carry a provenance marker; the guard requires it (2026-09-20)

**Repo:** EDDI (`fix/vault-references-in-templates`)

### Why

`ConfigReferenceGuard.autoVaultReferences` decided whether a conversation property named by an HTTP-call template may resolve a vault secret by **looking at the value**: it accepted the property when its value was character-for-character what `PropertySetterTask.autoVaultSecret` would have written for that property, under this conversation's agent and tenant. Two PR reviewers (Copilot, CodeRabbit) asked for a provenance check instead, and the previous entry recorded why it was not done then: `Property` carried no marker. A `scope: "secret"` instruction stores its vault reference with `scope: conversation`, indistinguishable on disk from a property a template wrote from user input, a model reply or an API response — so `${vault:<agentId>.apiKey}` was a string an attacker could simply produce, and the shape test accepted it.

What that bought an attacker was bounded (the key is derived from the agent and the property name the template reads, and the request goes to the endpoint the configuration names), which is why it shipped. It is still a value the configuration never wrote being resolved into an outgoing request.

### What changed

* **`Property.autoVaulted`** (new, `Boolean`) — the provenance marker, set by `PropertySetterTask.autoVaultSecret` and by **nothing else**. No property-instruction field maps to it (`convertPropertyInstructions` reads a fixed key set), and no REST endpoint takes a `Property` as a request body, so "marked" means "this process vaulted it" rather than "this value looks vaulted".
* **`ConfigReferenceGuard`** requires the marker before it will allow a reference read through `{properties.x}`. The agent/property/tenant comparison is kept behind it — redundant by construction, since `autoVaultSecret` derives all three itself, and kept as the bound that still holds if a marked `Property` ever reaches memory from somewhere other than that method.
* **`ApiCallExecutor`** passes the live `Map<String, Property>` from `IConversationMemory` into `buildRequest` → `resolveGuardedVariables` → the guard. `ConversationProperties.toMap()` — what templates and, until now, the guard see — flattens each `Property` to its raw value and loses the marker, so it needs a channel of its own. Read from memory at build time, not captured earlier: a pre-request property instruction writes through to the same map between `execute` being called and the request being built. Threaded through `execute`, `resolve` and `executeFireAndForgetCalls`.
* **`MemoryCheckpoint.copyProperties`** carries the marker across the deep copy. It clones through the all-args constructor, which does not take the new field — a rollback that dropped it would turn every later API call using that secret into a refusal.
* `docs/secrets-vault.md`: the auto-vaulted-property case now rests on provenance, and what an unmarked property means.

### Decision: unmarked is refused, not grandfathered

`null` covers two cases that cannot be told apart — a property written from conversation data, and one written into a conversation document before the field existed. Accepting the pair for compatibility would leave the hole open permanently, because the attacker's property is unmarked too; the fix would be decorative. So unmarked fails closed.

The cost is a conversation that auto-vaulted a secret under an earlier release and makes the API call after the upgrade: the call is refused with the error that names the field and the reference, and re-running the `scope: "secret"` instruction (the user supplies the secret again, or a new conversation starts) marks it. Bounded — it needs the vault enabled, which is not the shipped default — and recoverable. A permanent fail-open is neither.

Deserialization stays backward compatible in the mechanical sense: the field is absent from every document already in MongoDB and reads back as `null` rather than failing, and EDDI's global `NON_NULL` inclusion means an unmarked property does not gain the field on write either.

### Verification

* `ConfigReferenceGuardTest` 10 → 13: the three existing auto-vault cases kept (`autoVaultProperty`, `autoVaultTenantIsPinned`, `autoVaultOwnTenant`, now stating the marker explicitly — tenant pinning still refuses a *marked* property under a foreign tenant, so provenance is necessary and not sufficient), plus an unmarked property holding the exact reference, an explicit `FALSE`, and no properties at all.
* `ApiCallExecutorConfigReferenceTest` +1: the request `autoVaultedPropertyHeader` sends, refused byte-for-byte when the same property is unmarked — the vault is never asked and nothing is sent.
* `PropertySetterTaskSecretScrubTest` +2: a `scope: "secret"` write is marked; an ordinary write of the identical string is not.
* `PropertyTest` +5 (JSON round-trip, an unmarked property omits the field, a pre-marker document reads back unmarked), `MemoryCheckpointTest` +1 (the marker survives the deep copy).
* Mutation-checked one at a time: the guard ignoring the marker fails 3 tests, `autoVaultSecret` not writing it fails 1, the checkpoint clone dropping it fails 1.
* apicalls, properties, memory and secrets suites plus the repo-wide guards: 6479 tests green.

## 📝 docs(planning): more knowledge-base source types — sitemap, upload, Drive, SharePoint, mail (2026-09-19)

**Repo:** EDDI (`docs/rag-source-types-plan`)

New plan: [`planning/rag-ingestion-source-types-plan.md`](https://github.com/labsai/EDDI/tree/main/planning/rag-ingestion-source-types-plan.md). It extends the RAG ingestion stack (#783–#787, #789, #790) from web crawling to further source types: sitemap-only, file upload, Google Drive, Microsoft OneDrive/SharePoint, and email (Gmail, Microsoft 365, IMAP), with shorter notes on Confluence, Notion, S3, Git, Slack and help-desk systems.

**Decisions recorded in the plan:**

* A `SourceConnector` interface replaces the pipeline's hard-wired crawler; the web source moves onto it with its tests unchanged. Per-source change cursors (Drive changes, Graph delta, Gmail history, IMAP CONDSTORE) commit only with a successful run, and delta sources tombstone on explicit deletions instead of missed runs.
* Credentials come from the existing connections framework. Scheduled runs use `SERVICE` connections; a `PER_USER` grant is spent without its owner present only for a source that owner saved, and by default only into a knowledge base whose `audience` is `OWNER` — enforced at retrieval in `RagContextProvider` and failing closed without a verified principal. The one exception is an admin-acknowledged Gmail shared support mailbox, which must match the linked grant's own address.
* Ingested text is treated as attacker-writable: retrieved context is framed as data, hidden HTML is dropped, and mail sources carry sender filters, redaction before embedding, and mandatory retention.
* Least privilege is the documented path: Shared Drive membership, `Sites.Selected`, mailbox-restricted `Mail.Read`. No domain-wide delegation. Office formats through Apache POI rather than Tika.
* Order: foundation + sitemap, then upload (brings the converters), SharePoint (no new auth work), Drive (adds a JWT-bearer auth type), audience/run-as grants, and mail last.

**Files:** `planning/rag-ingestion-source-types-plan.md` (new), `docs/changelog.md`.

**Next:** nothing is implemented; the plan waits on the ingestion PRs merging. Open questions are in its §13.

***

## 📈 docs(metrics): chart and document `eddi_conversation_store_conflict_count` (2026-09-19)

**Repo:** EDDI (`fix/conversation-turn-concurrency`, follow-up in PR #791)

The revision-guard commit registered `eddi_conversation_store_conflict_count` but did not add it to `docs/metrics.md` or `docs/monitoring/eddi-full-metrics-dashboard.json`, so `MetricsDashboardCoverageTest` failed in CI (`everyRegisteredMeterIsCharted` and `everyRegisteredMeterIsDocumented`). The counter now sits beside undo/redo in the conversation-operations panel (legend "store conflict") and in the Conversation Metrics reference. A non-zero rate means turn, undo/redo or resume writes that were refused because another writer changed the conversation first. Before the revision guard, those were silent data loss.

Review follow-up in the same PR: `PostgresConversationMemoryStoreUnitTest` now stubs the existence probe with its own statement and result set, and verifies the store closes the probe's result set. Before, it reused the shared update mocks, which a code-quality bot reported as a possible `ResultSet` leak.

### Review follow-ups (2026-09-21)

* **The lifecycle panel still said "All six".** `id: 9` has carried a seventh target since the store-conflict counter landed. The description is now "Every `eddi_conversation_*_count_total` counter" rather than a number that goes stale the next time one is added.
* **The probe-close assertion could not see half of what it claimed.** `stubConversationExists` returned only the `ResultSet`, so its own comment — "the test verifies the store closes both" — was not true of the `PreparedStatement`. It now returns a `ProbeResources` record and both tests verify `close()` on each. `ResultSet.close()` is not specified to close the statement that produced it, so the missing half is the one that would leak a server-side portal per refused write.
* **A dead full-document serialization ran on every store.** `storeConversationMemorySnapshot` opened with `jsonSerialization.serialize(snapshot)` whose result no branch used: the append path returns before reaching it, the full-replace path overwrites it after stamping the new revision, and the insert path serializes again once the id exists. On the append path that is exactly the cost the append path exists to avoid — one full serialization of the whole conversation, every turn, thrown away. Removed; `storeSnapshot_pureAppend_serializesTheSnapshotOnceForTheBody` pins it at the one call `appendConversationSteps` legitimately makes for the body.

The `IN_PROGRESS` entry in `NON_APPENDABLE_STATES` was also queried in review and is left as is: `Conversation.runStep` sets that state on the in-memory snapshot only, and the say path persists once from `ConversationStepRunner.onComplete`, after `runStep`'s `finally` has restored `READY`. A persisted `IN_PROGRESS` means a resume is running — which is the lost update the filter is there to refuse.

***

## 🔒 fix(hitl): MCP `get_approval_status` detail=full no longer serves raw tool arguments (2026-09-19)

**Repo:** EDDI (`fix/mcp-approval-detail-redaction`)

The REST `GET /agents/{id}/approval-status?detail=full` returns the snapshot through `ConversationMemoryUtilities.sanitizePendingToolCallsForApprover`, which removes the pending batch's `argumentsRaw`, `chatTranscriptJson` and `traceSoFar`, masks the request fingerprint and re-redacts the served arguments and preview. The MCP mirror, `McpHitlTools.getApprovalStatus`, called only `stripRequestFingerprintsForRead` — one step of that method — so every MCP caller the read gate admitted received the raw arguments of each gated tool call (a clear-text API key has been observed there) plus the full serialized LLM transcript. On a deployment without OIDC, `eddi.mcp.allow-unauthenticated=true` makes that surface reachable from the network.

### What changed

* **`McpHitlTools.getApprovalStatus`** — `detail=full` now serializes `sanitizePendingToolCallsForApprover(snapshot)`, the same call the REST surface makes.
* **`ConversationMemoryUtilities.stripRequestFingerprintsForRead` is now `private`.** The divergence existed because there were two public projections to choose from; now there is one, and the compiler refuses the old call. Its Javadoc and the sanitizer's say both surfaces must use the sanitizer.
* **`McpHitlToolsTest.getApprovalStatus_detailFull_neverServesRawArgumentsOrTranscript`** — builds a paused snapshot with a canary in `argumentsRaw`, `chatTranscriptJson` and `traceSoFar`, serializes through the real `JsonSerialization` (a mocked serializer hides which fields ride along), and asserts the canary is absent while `argumentsRedacted` is still served. It fails on the previous code.
* **`docs/hitl.md`** — the approver read-scope paragraph states the sanitization applies to REST and MCP.

### Decisions

* **Group variant: no change.** `get_group_approval_status?detail=full` (MCP) and its REST twin both return the `GroupConversation` unmodified. That document carries no `PendingToolCallBatch`, no `argumentsRaw` and no LLM transcript JSON — its `transcript` is the group discussion itself, which is the documented content of the full view, and `hitlLastPauseFingerprint` digests task state, not a request. Member tool-call pauses inside group turns (`inGroupTurns: INBOX`) are still reserved, so nothing gated per call is stored on the group document. Both surfaces already serve the same thing.
* **Readers swept, already safe:** REST `approval-status` (both views — summary builds `pauseDetails` from `argumentsRedacted`, re-redacted); `RestConversationStore` raw log (`redactRawPendingToolCallsForRead`, names only) and simple log (`convertSimpleConversationMemory`, names only); `ConversationService.readConversation` and every say/resume response (`convertSimpleConversationMemorySnapshot`, names only) and so MCP `read_conversation`, the OpenAI-compatible bridge, `ConverseWithAgentTool` / `CreateSubAgentTool` (tool names only); Slack approval cards (`SlackHitlSupport` reads `argumentsRedacted`, re-redacted); GDPR Art. 15 export (conversation outputs only); the HITL audit entry (`argsDigest`, a SHA-256 — not the arguments); `RestToolHistory` (step traces, owner-only, not the pending batch); `RestTemplatePreview` (`MemoryItemConverter` exposes no HITL fields).
* **The concurrent change landed first, and this branch absorbed it.** `fix/gemini-thought-signatures` (#794) added `PendingToolCallBatch.gatingAssistantMessageJson`, which embeds every gated call's raw arguments. It dropped that field inside `stripRequestFingerprintsForRead`, with the reasoning that the method was "the one method every full-detail read calls — including the MCP approval-status tool, which calls nothing else". That premise is exactly what this branch removes, and its own sanitizer already nulls the field, so the merge keeps the strip there and drops the duplicate: one projection, one place a new raw-argument field has to be listed. The test that asserted the partial strip drops the field now asserts that the partial strip is private — the invariant that kept the two doors from drifting again.

***

## 🐛 fix(operator): review follow-ups on the self-URL fix — stricter origin, later retirement, factual tool errors (2026-09-19)

**Repo:** EDDI (`fix/operator-self-url`) — backend and `ui/manager/`, follow-up to the entry below (PR #795 review)

**What changed**

* **`eddi.self.base-url` must be a bare origin.** `SelfUrlResolver` now rejects a value with a path, query, fragment or userinfo (falls back to loopback, logged at ERROR). Every consumer appends an API path verbatim, so `https://eddi.internal/base` silently retargeted every call and `http://eddi:7070?tenant=x` turned every path into query content. The Manager's activation form applies the same rule by parsing with `URL` (`isOriginOnlyBaseUrl` in `lib/api/operator.ts`) instead of a character class that still admitted `?` and `user:pass@`.
* **Plain-HTTP, non-loopback self URL is warned about, not refused.** `${caller:token}` is released to that address, so the resolver logs a WARN at startup that the token will cross the network unencrypted unless a mesh protects it. Refusing it would break the in-cluster service-name case the override exists for.
* **The superseded operator is retired only after the replacement passes verification.** `useActivateOperator` used to undeploy and delete the predecessor before `verifyGateInstalled` / `enforceGateDryRun`; when either rolled the replacement back, the deployment had no operator at all. Retirement now runs last, and the failure path (`handBackToPredecessor`) never retires the predecessor. It asks the agent store whether the replacement still exists — via `GET /agentstore/agents/{id}/currentversion` (200 present, 404 absent; new `getAgentCurrentVersion` in `lib/api/agents.ts`), NOT the version-less `GET /agentstore/agents/{id}`, which a live 6.4 answers with 400 for an existing agent and an unknown id alike — rather than inferring it from the config variable (`resetOperator` deletes the agent before clearing the variable, so a failed clear leaves a config naming a deleted agent): gone means the predecessor's config is written back; still present — including a predecessor with no recorded version — means both are left and the error names both. Tests in `use-operator-supersede.test.tsx`, mutation-checked against the old ordering and the first version of the hand-back. Their agentstore mocks mirror the measured 6.4 behaviour (version-less GET → 400) and assert the exact URL the presence check calls: the first cut of this check used the version-less GET, and a mock that answered it with the document let it pass while it could only ever return "unknown" against a real backend.
* **Tool failure messages state facts, not reporting policy.** `HttpCallToolsProvider` no longer tells the model "report this to the administrator" or that a refused connection is "NOT a fault in the service" — a stopped listener refuses too. The connect-class message now says the request failed before any response and lists what to check (base URL reachable from the server, network path, listener); the SSRF message says no request was sent and that the base URL must pass the full SSRF policy.
* **Docs:** the SSRF guidance now says the self URL must pass the *whole* target policy (private and link-local are refused too, so an in-cluster name usually stays blocked); the `unresolved` self-URL case is documented in `httpcalls.md`, `hitl.md`, `AGENTS.md` and `ui/manager/AGENTS.md`; `eddi.self.base-url` and `eddi.caller-identity.self-release.enabled` are added to `configuration-reference.md` (CI's `ConfigurationReferenceCoverageTest` was red on their absence).
* **i18n:** corrected misspelled terms in the new `hi`, `ja`, `ko` and `th` strings.

**Design decisions:** the transport-security finding is answered with a startup warning rather than a refusal, for the reason above; the tool message keeps its diagnostics in the engine (every agent needs them) but drops the imperatives, which belong in an agent's prompt.

***

## 🔓 feat(secrets): a vault secret's agent grant is editable without its value (2026-09-18)

**Repo:** EDDI (`feat/editable-vault-grants`) — backend and `ui/manager/` together

### The failure this comes from

On a customer deployment, API keys were moved into the vault during an upgrade, with one key granted to exactly the agents that existed at the time. A newly deployed agent then referenced the same key, and `VaultGrantGate` correctly refused it:

```
Agent '0123456789abcdef01234567' v1 references vault secret(s) it is not granted: [${vault:llm-api-key}].
SecretMetadata.allowedAgents lists which agents may use a secret; widen the grant or remove the reference.
```

The message said "widen the grant". **There was no way to do that.** `PUT /secretstore/secrets/{tenantId}/{keyName}` was the only write path that touched `allowedAgents`, and `RestSecretStore.storeSecret` rejects a blank value with a 400 — so widening a grant required the plaintext, which an operator does not have once a key is vaulted. That *is* the point of vaulting it. The value had to be recovered from a pre-migration database backup and the whole secret re-PUT.

The worse consequence is the incentive. With no way to enumerate agents cheaply, the path of least resistance is to grant `["*"]` to everything — which is what that deployment did, and which discards the only control limiting a secret's blast radius. The missing endpoint did not merely inconvenience an operator; it pushed them to a wildcard grant.

### What changed — backend

**`PUT /secretstore/secrets/{tenantId}/{keyName}/grant`**, body `{allowedAgents, description?}`. `PUT` on a sub-resource rather than `PATCH` on the secret: the body replaces the grant wholesale, which is idempotent, and the sub-resource is what makes the value structurally unreachable — `GrantRequest` has no field that could carry it. `?dryRun=true` returns what the change *would* do without writing, which is what lets a UI warn before the operator commits rather than after. Same `@RolesAllowed("eddi-admin")` as every neighbouring endpoint.

**Plaintext is kept out by signature, not by convention.** Three new members, none of which takes a value:

* `ISecretProvider.updateGrant(reference, allowedAgents, description)` — no plaintext parameter, so it cannot read, re-encrypt or re-write the value even by mistake. It never calls `activeDek()`, so a grant edit does not need the tenant's key at all.
* `ISecretPersistence.updateSecretGrant(tenantId, keyName, allowedAgents, description)` — the Mongo `$set` and the Postgres `SET` list name **exactly two fields**. `encryptedValue`, `iv`, `dekId` and `checksum` are absent from both, and neither upserts: a grant for a key that does not exist updates no rows and surfaces as a 404 rather than leaving a valueless entry behind.
* `SecretMetadata.canonicalGrant` / `grantsAllAgents` / `WILDCARD_AGENT` — one definition of what "everyone" means, which `VaultGrantChecker.WILDCARD` now aliases instead of repeating.

**A grant edit is not a rotation.** `createdAt` and `lastRotatedAt` are untouched — they are not in the two-field write list — and both are echoed in the response so the operator can see it. New counter `eddi.vault.grant.update.count`, kept separate from stores so a spike in widening is distinguishable from a spike in rotation.

**The `SecretResolver` cache is deliberately not invalidated.** `storeSecret` has to, because the plaintext behind a cached entry may have changed; here it cannot have. And the cache plays no part in the grant decision — `VaultGrantChecker` calls `ISecretProvider.getMetadata`, which reads persistence on every call — so the new grant is in force for the very next deployment either way. Invalidating would only force a needless decrypt for every agent already using the key. The reasoning sits in a comment at the call site.

**Tightening a grant is reported, never silently applied.** New `VaultGrantImpactAnalyzer` answers "which deployed agents reference this secret and would not be granted it", and the response carries them as `agentsLosingAccess` plus a `warning`. It is a separate bean from `VaultGrantChecker` on purpose: the checker sits *underneath* `AgentFactory`, so giving it an `IAgentFactory` of its own would close a CDI loop. It works through a new `VaultGrantChecker.references(agentId, version, secret)` — the inverse question to `findUngrantedReferences`, reusing the same traversal including the `${connection:…}` hop, because "who would this break?" cannot be answered from the grant list that is being replaced.

### Design decisions

* **An omitted `allowedAgents` is a 400, not a default.** `storeSecret` defaults a missing list to `["*"]`, which is a reasonable convenience on a *create*. On an edit it would mean a field dropped from a JSON body silently opens a narrowed secret to every agent — the exact failure this work exists to remove. `["*"]` has to be said out loud.
* **`[]` is a 400 on this endpoint too.** Every other layer reads an empty list as "unrestricted", so a client that filtered its list down to nothing would otherwise open the secret to everyone with a 200. `["*", "someAgent"]` — which the deploy-time check already reads as open — is stored as `["*"]`, so a grant never reads narrower than it behaves. (`storeSecret` is unchanged and still stores what it is given.)
* **Resolution no longer re-upserts the whole row.** Found in review: `resolve()` recorded `lastAccessedAt` by writing back every field of the row it had read, so a resolve that read just before a grant edit silently reverted it (and could equally revert a rotation). It now goes through a single-field `ISecretPersistence.touchLastAccessed`.
* **The impact check looks at every deployed version**, via a new `IAgentFactory.getAllDeployedAgents`. The latest version can be registered but not `READY` while an older one is serving, and two versions can be `READY` at once; scanning only the latest per id missed both. The response marks the list `agentsLosingAccessScope: "this-node"`.
* **Narrowing warns rather than refuses.** The gate is deploy-time, so a deployed agent that loses its grant keeps running and fails its *next* deployment. Refusing the edit would be wrong; saying nothing would be worse. The Manager additionally requires the warning to be acknowledged before it will save.
* **No new "grant last changed" timestamp.** It would mean a schema addition in two persistences for marginal value; the change is logged at INFO with both the old and the new list instead. Agent IDs are not secrets.

### What changed — Manager UI

The **Allowed Agents** column is now the edit affordance (clicking it opens the editor), plus an explicit **Access** row action. New `EditGrantDialog` (`src/components/secrets/`): an all-agents / these-agents choice, agents added through the existing `AgentPicker` so they are chosen from a searchable list rather than typed from memory (a raw ID can still be entered), names resolved for the chips and the warning, and the dry-run warning with a mandatory acknowledgement. An empty specific list is refused in the UI, because the backend reads "no entries" as "everyone" and silently turning *I removed everyone* into *I allowed everyone* is the same bug in miniature. New `updateSecretGrant` client function, `useUpdateSecretGrant` / `useSecretGrantImpact` hooks, 16 i18n keys across all 11 locales. Save stays disabled while the dry run for the current list is in flight, and a failed dry run is its own warning needing an explicit "save anyway" — an acknowledgement is tied to the answer it was given for, so a changed answer needs a new one (the review found Save enabled throughout the check and on its failure). The description is sent only when it changed. `openapi-operations.json` is regenerated from this branch's backend, so the new endpoint is in the contract snapshot rather than exempted from it.

### Tests

`VaultSecretProviderGrantTest` (16) runs real AES-256-GCM over a stateful in-memory persistence, so "the value is unchanged" is proved by decrypting it again after the edit rather than by a mock echoing back what the test told it; one test interleaves a grant edit into the middle of a resolve. `MongoSecretPersistenceTest` +6 and `PostgresSecretPersistenceUnitTest` +4 pin the grant write to exactly two fields and the access stamp to one. `RestSecretStoreTest` +23 cover widen, tighten, wildcard set and cleared, 400 (omitted and empty list)/404/503/500, dry run, the warning, and that the cache is *not* invalidated. `VaultGrantImpactAnalyzerTest` (12) and a `references()` group in `VaultGrantCheckerTest` (7) cover short-form, full-form, other-tenant, legacy-prefix, `${connection:…}` and `${vars:…}` references; `AgentFactoryExtendedTest` covers `getAllDeployedAgents` on the real registry. Manager: `secrets-grants.test.tsx` (18). Every behaviour was mutation-checked — reverting it fails a named test — after an independent review found one (`references()`) that nothing covered.

### Files

* `src/main/java/ai/labs/eddi/secrets/` — `ISecretProvider`, `VaultGrantChecker`, **`VaultGrantImpactAnalyzer`** (new), `impl/VaultSecretProvider`, `model/SecretMetadata`, `persistence/{ISecretPersistence,MongoSecretPersistence,PostgresSecretPersistence}`, `rest/{IRestSecretStore,RestSecretStore}`
* `docs/secrets-vault.md` — a "Changing a grant without the secret's value" section; the old text told operators to "widen the grant" without saying how, which is how this happened
* `ui/manager/` — `src/pages/secrets.tsx`, `src/components/secrets/edit-grant-dialog.tsx`, `src/lib/api/secrets.ts`, `src/hooks/use-secrets.ts`, 11 locale files, MSW handlers, `src/test/mocks/openapi-operations.json` (regenerated)
* `src/main/java/ai/labs/eddi/engine/runtime/` — `IAgentFactory.getAllDeployedAgents`; `engine/setup/AgentSetupService` — its `vaultWarning` now points at `PUT …/grant` instead of a PATCH that never existed

### Review follow-up (2026-09-21): an acknowledged failed check no longer covers a different grant

Raised in review of [#796](https://github.com/labsai/EDDI/pull/796). The dialog blocks Save until the operator ticks the warning, and remembers *what* was ticked so that changing the proposal asks again. On the successful path that key is the list of agents that would lose access, which changes with the proposal. On the failed path every failure shared one literal key, `"check-failed"` — so ticking a failed check, changing the grant, and having the new check fail too left the old tick standing, and Save went through on a proposal whose impact nobody had seen. It is the one moment the warning exists for.

The failed-check key now carries the proposed grant. `secrets-grants.test.tsx` gained `an acknowledgement of a failed check does not carry over to a different grant`, mirroring the existing test for the successful path; mutation-checked by restoring the shared key, which turns exactly that test red.

***

## ⚡ perf(conversation): append the turn's steps instead of rewriting the document (2026-09-18)

**Repo:** EDDI (`fix/conversation-turn-concurrency`, follows the revision-guard commit)

Appending one step used to rewrite the whole conversation document. On a production 5.x database conversations average **410 KB**, so every turn sent 410 KB over the wire, rewrote the document and put the whole document in the oplog — to add a few kilobytes. The same whole-document cost is what made the 6.x startup migration take 24 minutes on that database.

It is also what forced the previous commit to *report* a lost update rather than repair it: a full-document replace has no safe retry, because re-applying it is the overwrite the revision guard exists to refuse.

### What changed

* **`IConversationMemory.getPersistedStepCount()`** — how many steps the document held when this memory was loaded. Set on load by `convertConversationMemorySnapshot` (only when the stored steps and outputs agree in count), carried into the snapshot by `convertConversationMemory`, and refreshed by `ConversationStepRunner` after every successful write.
* **Both stores take an append path when the write is a pure append** — the count is known, steps and outputs are still in step, and the count grew.
  * MongoDB: `$set` of every field the snapshot emitted, `$unset` of every `ConversationMemorySnapshot.TOP_LEVEL_KEYS` entry it did not, `$pushEach` of the new steps and outputs, `$inc` of `_rev` — all under the same revision filter.
  * PostgreSQL: `(body - 'conversationSteps' - 'conversationOutputs') || jsonb_build_object(…)` so the arrays are concatenated server-side.
* **A conflict on the append path is retried — but only over another append.** Every full-document write (insert, replace, conditional replace: undo, redo, rerun, a HITL pause or resume commit) now stamps a second marker, `_histRev`, with the revision it creates; an append leaves it alone. So `_histRev <= loadedRevision` holds exactly when every write since this turn loaded was an append, and only then is pushing our tail after the winner's correct. Each attempt's filter also requires a state a say turn may complete over (not `ENDED`, `AWAITING_HUMAN` or `IN_PROGRESS`). When those hold, the retry (up to `MAX_APPEND_ATTEMPTS` = 5) re-applies the same push and both turns survive in commit order — the "reload and re-apply" option the previous commit deferred. When they do not, the append throws `ConcurrentConversationModificationException` and the say path reports it exactly as commit 1 does.
* **A merged append leaves the memory marked stale.** After a retry the document also holds the winner's step, which the live memory does not; the snapshot stays on the loaded revision with an unknown step baseline, so a second write from that memory is refused instead of erasing the winner's step.
* **Only the tail is encoded.** Both stores serialize the snapshot with the two arrays swapped for their new tails (Mongo) or emptied (Postgres body), so the stored history is neither re-serialized nor re-sent.
* **Everything that is not a pure append keeps the full-document write**: a rerun (it re-executes the current step without starting a new one, so the count does not grow), an undo or a redo (`ConversationMemory.undoLastStep`/`redoLastStep` drop the baseline explicitly — a redo grows the count by one and would otherwise look like a fresh turn), and a document whose steps and outputs had drifted (the replace also repairs the drift).

### Decisions

* **The `$set` field list is derived from the encoded snapshot, never hand-written.** A maintained list would silently stop persisting the next field somebody adds — a worse bug than the one being fixed. Everything the codec emits is `$set`; the complement of `TOP_LEVEL_KEYS` is `$unset`, which is what makes the non-array part of the document come out exactly as `replaceOne` would have left it.
* **`TOP_LEVEL_KEYS` exists because the serialization omits nulls** (`NON_NULL`) while `$set` merges. Without the `$unset` half, a field the turn cleared would keep its stale value — and every fresh turn after a resolved pause clears the HITL bookmark via `Conversation.clearStaleToolPauseState`. It is derived by reflection over the declared instance fields, and `ConversationMemorySnapshotTopLevelKeysTest` fails if a future `@JsonProperty` rename is not mapped, because under-inclusion is the dangerous direction (over-inclusion is a no-op `$unset`).
* **Postgres gets the append for the merge, not for the bytes.** MVCC rewrites the row either way; what matters there is that a retry concatenates instead of replacing.
* **Cross-instance.** The guard and the retry preconditions live in one filtered `updateOne` / `UPDATE … WHERE`, evaluated by the database, so they hold between pods. This matters more than it first appears, and an earlier draft of this commit got it wrong: it retried unconditionally and claimed identical behaviour on one pod or ten. Within one pod a say turn's persist runs before the coordinator releases the next turn, so the only concurrent writers are the uncoordinated REST paths (undo, redo, end). Across pods a HITL pause committed on pod 1 can land between pod 2's load and its persist; an unconditional retry would then `$set` READY and `$unset` the bookmark, erasing a pending approval whose timeout stays armed. The `_histRev` and state preconditions are what make the retry safe in both settings. They also close a pre-existing cross-pod hole: `endConversation`'s narrow `setConversationState(ENDED)` does not bump the revision, so an in-flight turn elsewhere used to resurrect the conversation as READY.
* **What leaving the prefix untouched rests on — a convention, not a type.** Earlier steps are handed out as `IConversationStep` (no `storeData`), but the `IData` they return has setters, and their outputs — like every entry of `getConversationOutputs()` — are mutable maps. No production code mutates a prior step today (the readers were checked: InputParserTask, MemoryItemConverter, the behavior-rule matchers, PropertySetterTask, ConversationHistoryBuilder, ConversationSummarizer, ContextualToolsProvider; LifecycleManager's strict-write handling touches the current step only). A future caller that did would have its change dropped on the next append, which is why this is written down in `ConversationMemoryStore.isPureAppend`.

### Known residual

The `$set` fields — `conversationProperties`, `conversationState`, `pendingLongTermWrites`, the HITL bookmark — remain last-writer-wins on a retry, so two genuinely concurrent turns can still have the later one's property map win. That is the pre-existing semantics and unchanged for sequential turns; before this branch such a turn lost its whole step *and* its properties. Field-level property merging (dotted `$set` paths) would close it and is deliberately out of scope here.

Not tested: retry exhaustion (`MAX_APPEND_ATTEMPTS`) needs five interleaved writers between one attempt's filter and its write, which the store offers no seam to force.

Pre-existing and out of scope: startup migrations (`MigrationManager`, `V6RenameMigration.migrateEnvironments`) replace raw documents without touching `_rev` or `_histRev`, so during a rolling upgrade they can revert a concurrent append on another pod. The Postgres append falls back to `IN_PROGRESS` for a null state where the full-row path throws — an inconsistency copied from the existing conditional store.

A second, separate improvement is also out of scope: `ConversationService.say` returns HTTP 200 from inside the pipeline callable (`Conversation.runStep`'s `outputProvider.renderOutput`) while the persist happens afterwards, and it loads the memory on the REST thread before `conversationCoordinator.submitInOrder`. That is what makes the window wide enough for a strictly sequential client to hit. The append makes it harmless rather than closing it.

### Files

* `src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryStore.java`
* `src/main/java/ai/labs/eddi/datastore/postgres/PostgresConversationMemoryStore.java`
* `src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java`, `IConversationMemory.java`, `ConversationMemory.java`, `ConversationMemoryUtilities.java`
* `src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java`
* `src/test/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshotTopLevelKeysTest.java` (new)
* `src/test/java/ai/labs/eddi/datastore/mongo/MongoConversationTurnConcurrencyTest.java` — plus: an append does not merge over a pause, an undo, an undo hidden by a later append, or an `ENDED` state committed after its load; a merged append refuses a second write
* `src/test/java/ai/labs/eddi/datastore/postgres/PostgresConversationMemoryStoreTest.java` — the same pause/undo/ended cases, the conditional store's revision guard at SQL level, and an append on a row deleted mid-turn

### Mutation check

Forcing `isPureAppend` to false fails 3 MongoDB tests — `overlappingTurnsBothSurvive` and `threeOverlappingTurnsAllSurvive` raise `ConcurrentConversationModificationException`, and `appendDoesNotRewriteThePrefix` finds its out-of-band sentinel erased — and 1 Postgres test (`overlappingTurnsBothSurvive`). Removing the retry preconditions (filter and re-check) on both backends fails 7 tests: the four Mongo rewrite/ended cases and the three Postgres ones. Replacing the merged-append invalidation with a normal stamp fails `mergedAppendInvalidatesTheMemory`.

***

## 🐛 fix(conversation): optimistic concurrency on the conversation document (2026-09-18)

**Repo:** EDDI (`fix/conversation-turn-concurrency`)

A turn's reply could be handed to the client with HTTP 200 and `conversationState: READY` and the turn then be discarded — no error, no 409, nothing in the log. Measured on a live 6.4.0 instance: three turns posted to `POST /agents/managed/{intent}/{userId}` back to back, each awaiting its own 200, produced a conversation holding the greeting, turn 1 and turn 3. Turn 2 was absent from MongoDB entirely. A 3 second gap between turns stored all four outputs.

### The mechanism, as verified in this branch

Two things compose:

1. **The reply leaves before the turn is durable.** `Conversation.runStep` calls `outputProvider.renderOutput(memory)` in its `finally` block, and that is the lambda `ConversationService.say` passes to `agent.continueConversation` — so `responseHandler.onComplete` (HTTP 200) fires from *inside* the pipeline callable, while the persist happens afterwards in the runtime's `onComplete` (`ConversationStepRunner.runGuardedConversationStep`). A client that waits for every 200 can therefore still post the next turn before the previous one has been written.
2. **The next turn's load is not serialized, and the write replaced the whole document.** `ConversationService.say` calls `loadConversationMemory` on the REST thread, *before* `conversationCoordinator.submitInOrder`. So turn N+1 can load a snapshot that predates turn N's store, append its own step, and write the whole document back via `replaceOne({_id}, snapshot)` — matching on the id alone. Last writer won, silently.

`MongoConversationTurnConcurrencyTest` reproduces both shapes against a real MongoDB (Testcontainers) through the production conversion path, and `threeSequentialTurnsKeepEveryTurn` reproduces the reporter's exact symptom: stored inputs `[greeting, turn 1, turn 3]`.

### What changed

* **`ConversationMemorySnapshot._rev`** — a monotonic revision on the conversation document. A loaded snapshot carries the revision it was loaded at; `IConversationMemory.getRevision()` carries it across the turn (set by `convertConversationMemorySnapshot`, read back by `convertConversationMemory`).
* **Both snapshot-store methods are now revision-guarded.** `ConversationMemoryStore.storeConversationMemorySnapshot` filters on `{_id, _rev: loaded}` and stamps `_rev: loaded + 1`; a zero-match throws the new `ConcurrentConversationModificationException` (a `ResourceStoreException` subtype). `storeConversationMemorySnapshotIfState` — the undo/redo and HITL-resume path — now filters on the revision **in addition to** the conversation state, and returns `false` on a miss (which the REST layer already maps to 409).
* **`PostgresConversationMemoryStore` keeps parity** via `WHERE id = ? AND COALESCE((data->>'_rev')::bigint, 0) = ?`.
* **A zero-match is disambiguated with one point-read** so the two causes get different answers: the conversation is gone (deleted mid-turn — a plain `ResourceStoreException`, nothing to retry against) versus present at another revision (a conflict a retry from a fresh load can still resolve).
* **The say path reports the conflict loudly**: `ConversationStepRunner.reportStoreConflict` increments `eddi_conversation_store_conflict_count` and logs ERROR naming the revision the turn was built on.
* **A conditional-store miss is diagnosed, not assumed.** `storeConversationMemorySnapshotIfState` now misses for two reasons, and its callers (say-path pause commit, HITL resume, undo, redo) were written when there was one ("a concurrent end/cancel moved the state"). On a miss, `ConversationStepRunner.diagnoseConditionalStoreMiss` reads the stored state once: if it still equals the expected state, the revision failed, so it counts the conflict and logs it at WARN. The callers' own messages now say "state or revision". Without this, a revision conflict on those paths would have been logged as a benign handover and never counted — silent again.

### Decisions

* **Detect and report, do not merge — yet.** By the time the persist runs, the reply is already with the caller, so neither a 409 nor a re-execution is available on the say path. A "reload and re-apply" is only safe once re-applying means *appending* rather than replacing, which is the follow-up commit. This commit's job is to convert a silent loss into an attributable one: a dedicated exception, an ERROR line and a metric.
* **A conflict does NOT flip the conversation to ERROR.** `logConversationError` would, but the document on disk belongs to a writer that succeeded; breaking a healthy conversation because this turn lost the race trades one wrong outcome for another.
* **Cross-instance by construction.** The guard is a filtered `updateOne`/`UPDATE … WHERE` evaluated by MongoDB or PostgreSQL, not a JVM lock. It holds between pods, between the say path and the undo/redo path, and between EDDI and any other writer of the collection — which is the point: `InMemoryConversationCoordinator` only serializes turns within one pod, and the load happens outside even that.
* **Narrow field updates deliberately do not bump `_rev`.** `setConversationState`, `compareAndSetState` and `clearHitlBookmark` are already arbitrated by the conversation-state CAS; bumping there would turn existing intentional handovers (a watchdog parking a turn as `EXECUTION_INTERRUPTED` while that turn is completing) into write conflicts.
* **Legacy documents upgrade without a migration.** A document with no `_rev` reads as `UNVERSIONED_REVISION` and its filter is `{_rev: 0} OR {_rev: {$exists: false}}` — MongoDB's `{_rev: 0}` does not match a missing field, so without the `$exists` half every pre-upgrade conversation would have been bricked on its next turn. `legacyDocumentWithoutRevisionUpgrades` covers it.

### Files

* `src/main/java/ai/labs/eddi/engine/memory/ConcurrentConversationModificationException.java` (new)
* `src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryStore.java`
* `src/main/java/ai/labs/eddi/engine/memory/IConversationMemoryStore.java`
* `src/main/java/ai/labs/eddi/engine/memory/ConversationMemory.java`, `IConversationMemory.java`, `ConversationMemoryUtilities.java`
* `src/main/java/ai/labs/eddi/engine/memory/model/ConversationMemorySnapshot.java`
* `src/main/java/ai/labs/eddi/datastore/postgres/PostgresConversationMemoryStore.java`
* `src/main/java/ai/labs/eddi/engine/internal/ConversationStepRunner.java`, `ConversationService.java`
* `src/main/java/ai/labs/eddi/engine/internal/ConversationHitlService.java` (log wording)
* `src/test/java/ai/labs/eddi/datastore/mongo/MongoConversationTurnConcurrencyTest.java` (new)
* `src/test/java/ai/labs/eddi/engine/internal/ConversationServiceStoreConflictTest.java` (new) — the say-path conflict is counted and does not flip the conversation to ERROR; a conditional-store revision miss is counted, a state miss is not
* `src/test/java/ai/labs/eddi/engine/memory/ConversationMemoryStoreTest.java`, `ConversationMemoryStoreResilienceTest.java`, `src/test/java/ai/labs/eddi/datastore/postgres/PostgresConversationMemoryStoreUnitTest.java`

### Mutation check

Disabling the revision half of `revisionFilter` (returning the id filter alone) fails 4 tests, including both invariant reproductions — `overlappingTurnIsNotSilentlyLost` reports `[greeting, turn B]` and `threeSequentialTurnsKeepEveryTurn` reports `[greeting, turn 1, turn 3]`. Removing the conflict-counter increments fails 2 of the 3 `ConversationServiceStoreConflictTest` tests.

### What's next

The write amplification is untouched: a turn still rewrites the whole document to append one step (410 KB average on a production 5.x database). The follow-up commit replaces that with `$push` of the new steps, which also turns a conflict into a retryable merge instead of a reported loss.

***

## 🧹 chore: replace a customer name used in examples and tests with a generic one (2026-09-18)

**Repo:** EDDI (`chore/genericise-customer-examples`); mirrored on the archived EDDI-Manager repo's branch of the same name

A worked connection example, a planning section, two changelog entries, one Javadoc and the `CALLER_SUPPLIED` test fixtures named a real integrating customer. They now use generic values throughout: connection `acme`, host `https://api.example.com` (RFC 2606), header `X-Acme-Key`, and prose that describes the requirement ("an integration that hands EDDI the end user's own API key") rather than who had it.

* **Docs:** `docs/connections.md`, `planning/saas-connectors-plan.md` §5.5, one line of this file, and the `CALLER_SUPPLIED` entry in the `docs/changelog/2026-08.md` archive (wording only, structure untouched).
* **Code:** the `RequestRedactor` Javadoc only.
* **Tests:** `ConnectionConfigurationValidationTest`, `RestConnectionStoreWriteGuardTest`, `ConnectionResolverTest`, `ConnectionStartupGuardTest`, `CallerIdentityContextTest`, `ApiCallExecutorConnectionHeaderTest`, and under `ui/manager/` the connection tests, the MSW fixture and `HANDOFF.md`. The replacement is used consistently in setup and assertion; no behaviour changed, and the same tests pass before and after.

The removal is from the tree only. Git history is not rewritten — that would need a force-push to `main`.

***

connection on `X-Amp-Id` or a `CALLER_SUPPLIED` one on `X-Acme-Key` matched none, so the live

## 🖥️ feat(manager): ingestion sources panel for knowledge bases (2026-09-18)

**Repo:** EDDI (`feat/manager-ingestion-sources`, stacked on `feat/rag-ingestion-rest`)

### What it adds

A section inside the RAG editor listing a knowledge base's ingestion sources, with add, edit and remove, and — once the knowledge base is saved — run, preview, purge and recent run history.

### Why a rewrite rather than a port

The stale `labsai/EDDI-Manager#95` (now archived with that repository) targeted standalone `/ragstore/ingestion-sources` resources. Sources now live on the knowledge base as `sources[]`, so creating or editing one is an ordinary save of the RAG config, and only the four runtime verbs have endpoints of their own. The resulting component is considerably smaller than the 846-line original.

### Decisions

* **A new source cannot be run.** It has no id for the endpoints to address until the knowledge base is saved; the panel says so instead of offering a button that 404s.
* **Preview is headed "nothing was embedded".** A preview that looked like a run would be worse than none.
* **Purge goes through `AlertDialog`**, since the next run re-embeds everything the source had ingested.
* **Run history polls only while a run is `RUNNING`.** A crawl takes minutes and the start endpoint answers 202; an idle screen should not tick forever.

### Contract snapshot

`ui/manager/src/test/mocks/openapi-operations.json` was regenerated from the OpenAPI document Maven produces. `openapi-contract.test.ts` failed until it was, because the new MSW handlers mocked endpoints the old snapshot did not know — and the regenerated diff was exactly the four new endpoints, nothing else.

### Tests

16 cases in `resource-detail-rag-sources.test.tsx` (9 when this entry was written, 7 added by later review rounds); 6558 passing across 413 files. `lint`, `typecheck`, `i18n:check` and `build` all pass, with translations for all 11 locales in the same commit.

***

## 🔧 fix(rag): review findings on the ingestion salvage (2026-09-18)

**Repo:** EDDI (`feat/rag-ingestion-rest` and the branches below it)

A critical review of the six salvage branches returned ten must-fix findings. All are fixed, each with a test that fails when the fix is reverted. The headline four fixes the salvage was built around held up under mutation; every finding below was in a seam those tests did not reach.

### Would not have started at all

`PostgresIngestionStateStore` was `@ApplicationScoped` without `@DefaultBean` while `DataStoreProducers` also produces `IIngestionStateStore` — two non-default candidates, so ArC fails augmentation and nothing boots. Every sibling Postgres store carries `@DefaultBean` for exactly this reason. No unit test can see this; the review caught it by reading the pattern.

### Silent data loss

* **A tombstoned page that came back unchanged was never re-embedded.** Its vectors had been deleted, so comparing hashes alone made it "unchanged" forever and permanently unretrievable. Two routes, both closed: `hasChanged` is now true for a tombstoned document, and it is no longer revalidated with its stored ETag (a 304 never re-embeds).
* **Schedules were orphaned.** `syncSchedules` walked only the new document's sources, so a source deleted from `sources[]` kept its schedule and went on crawling a third party on a cron with nothing left in the configuration to switch it off. The previous version's source ids are now diffed.
* **Deleting an old version killed the live version's schedules.** Removal now happens only when no readable version remains.
* **A duplicated knowledge base shared the original's ingestion identity.** Vector stores are keyed by name, which a duplicate shares, so the copy's runs replaced and tombstoned the original's chunks while the original's state still said "unchanged". A copy now gets fresh source ids and no cron.
* **Vector removal ran after the tombstone was recorded**, and a tombstoned document is never reported again — so a failed removal orphaned those vectors permanently. A failure now un-tombstones for retry.

### Runs that wedged their own source

* Only `crawler.crawl` was guarded and only `RuntimeException` caught, so a state-store failure or an `Error` left the run `RUNNING` forever: every later manual run a 409, every scheduled fire a failure. Everything after the claim is now guarded by `Throwable`, and `reapStaleRuns` is finally called before claiming — nothing called it, although the interface said otherwise.
* `ALREADY_RUNNING` and `SKIPPED` were mapped to `FireStatus.FAILED`. The schedule lease is five minutes and a crawl's default budget is ten, so the schedule is legitimately re-claimed while the first run is still going; the second fire lost the single-in-flight race and was recorded as a failure, dead-lettering the schedule within days.
* The converter's recursive walk had no depth cap. jsoup builds the full DOM — 60,000 levels if the page says so — and a `StackOverflowError` is an `Error` that sails past every `catch (Exception)`.
* Body reads had no time bound: `HttpRequest.timeout` covers headers only, so a server trickling one byte per second held a virtual thread indefinitely.

### Crawler correctness

* Only successful pages entered the visited set, so a dead link in a site-wide footer was fetched once per referring page — N errors, and a fetch budget so exhausted that the crawl never reported full coverage, which silently disabled deletion reconciliation for that site on every run.
* `isHtml()` matched any content type containing `xml`, so a linked sitemap or RSS feed became a knowledge-base document of concatenated `<loc>` URLs.
* robots.txt was fetched for the seed host and applied to every host reached.

### Divergent backends

The Postgres store swallowed every `SQLException` and returned a plausible answer — `lookup` said "never ingested" (re-embedding the page every run), `startRun` said "already running" (a 409 for a database fault), `finishRun` left the run `RUNNING`. Mongo threw. Both now fail identically through `IngestionStateStoreException`, which is the divergence the shared contract exists to prevent.

### Also

Preview was synchronous, unguarded and uncapped — up to a day on a request thread per click; it now runs with a two-minute budget. `runAsync` leaked an `ExecutorService` per call and swallowed `Error`s. Third-party titles are capped before being copied onto every segment. `ref`/`referrer` are no longer stripped as tracking parameters (they select content on plenty of sites). `RobotsPolicy` matched `User-agent` by substring, so `User-agent: a` captured every crawler.

### Tests

398 across the stack, up from 354: 20 new cases, one per finding, each mutation-checked. The repo's own `ImportStyleTest` caught two inline FQNs I had introduced.

## 🐛 fix(llm): Gemini 3.x could not use tools at all — thought signatures were dropped (2026-09-18)

**Repo:** EDDI (`fix/gemini-thought-signatures`, branched from `origin/main` @ 798c6e84d)

Every Gemini 3.x model was unusable with tools. On a production agent using function calling on `gemini-3.8-flash`, the first tool call failed with `400 INVALID_ARGUMENT — Function call is missing a thought_signature in functionCall parts`. Gemini 3.x attaches an opaque `thoughtSignature` to `functionCall` parts and requires it echoed back when that model turn is replayed on the follow-up request carrying the `functionResponse`. `gemini-3.8-flash` and `gemini-3.5-flash` reject the replay without it, `gemini-2.5-flash` tolerates it, and `thinkingBudget: 0` does not help — measured table in [`langchain.md`](/agent-configuration/langchain.md).

### Where the fix landed, and why

langchain4j 1.20.0 **already models the field**, so this is neither an upgrade nor an adapter. It gates both halves behind builder flags that default off: `PartsAndContentsMapper` captures the signature into `AiMessage.attributes()["thinking_signature"]` only when `returnThinking == TRUE`, and re-sends it only when `sendThinking == true`. EDDI set neither. Four places dropped the field:

1. **`GeminiLanguageModelBuilder`** — both flags now default **true** on `build` and `buildStreaming`, overridable as `recognisedParameters`. A default rather than opt-in: this is protocol correctness, not something an agent designer should learn from a 400. `ModelParameterValues.booleanValue(params, key, default)` reads it, so a typo falls back to the default instead of `Boolean.parseBoolean`'s silent `false`.
2. **`ToolApprovalGateSupport.normalizeToolCallIds`** rebuilt the message with `AiMessage.from(...)`, which carries only text and requests, so enabling the tool-approval gate alone broke Gemini 3.x again. Now `toBuilder()`. Blank text still collapses to null: the message is replayed, the Gemini mapper sends whitespace-only text as its own part, and Anthropic rejects such blocks.
3. **`gatingAssistantMessageOf`** read `getLast()`, but in a mixed batch the ungated calls execute and append their results *before* the pause is snapshotted, so it found nothing. It now walks back over this batch's tool results, stopping at anything else so it cannot borrow an earlier turn's message. `interimTextOf` had the same pre-existing blind spot — approvers lost the model's narration on mixed batches — and shares the walk.
4. **`ToolLoopResumer`, degraded resume** (transcript over its byte cap) replayed a bare `AiMessage.from(requests)`. `PendingToolCallBatch.gatingAssistantMessageJson` now keeps the gating message — written only when the transcript was omitted (a kept transcript already carries it, and a codec change would break both copies alike), capped at 64 KB on its own — never by the transcript's cap, since a small transcript cap is what triggers this path — shedding text then thinking before its attributes. `gatingExchange` replays it **unchanged**, original parts in original order, answering each ungated call with `HANDLED_BEFORE_PAUSE` — handled, not "ran", since an ungated call may have been refused or failed, and the outcome is what this path lost.

**Why replay the original parts** rather than rebuild from the gated calls: Gemini signs part 0 of a parallel batch. Measured once against the live Gemini API (3.8 and 3.5 Flash), moving that signature onto a different call was accepted — as were the original parts in order; only an unsigned replay was rejected. So the rebuild works today, on undocumented leniency that would fail on the rarely exercised degraded path if Google tightened it. Replaying what the model emitted stays valid, matches the shape the primary resume path already produces, and records the ungated call.

**Security.** The new field embeds the gated calls' raw arguments — the content `sanitizePendingToolCallsForApprover` strips `argumentsRaw` for. The first cut excluded it from the names-only projection but not from the approver `detail=full` surface; it is now dropped in `stripRequestFingerprintsForRead`, which every full-detail read calls (REST through the approver sanitizer, the MCP approval-status tool directly), and named in the sanitizer too. Canaries pin all three projections. *Pre-existing and out of scope:* the MCP `detail=full` path applies only the fingerprint strip, so it already serves `chatTranscriptJson` and `argumentsRaw` today; only the new field is closed there.

**Multi-turn.** The HITL tool pause is the only place a tool-carrying model turn crosses a request boundary and a MongoDB write; `AiMessage.attributes()` round-trips langchain4j's codec. Cross-turn replay needs no signature: `ConversationHistoryBuilder` rebuilds prior assistant turns as text only.

**Precondition, recorded where the flags are set.** The mapper joins every part's signature into one attribute and re-sends it on the first `functionCall`. Measured once, every turn shape carried exactly one signed part (a narrating text part is unsigned). Thought parts could add more — they appear only with `thinkingConfig`, which EDDI does not expose; re-check before exposing it.

### Tests

`GeminiThoughtSignatureTest` swaps in langchain4j's own `HttpClient` through a package-private builder seam, so the real model, mapper and codec run with only the socket replaced. The stub **enforces** Gemini's rule (an unsigned `functionCall` gets the real 400) and an opt-out test proves the check is live. Streaming is driven over two SSE frames, so attributes merge across frames. Mutations, each failing the intended test: both flags removed; `sendThinking` only (the verbatim 400); `sendThinking` in `buildStreaming` only; `normalizeToolCallIds` back to `AiMessage.from`; `gatingAssistantMessageOf` back to `getLast()`; the bare degraded rebuild; the field written with a kept transcript; either leak strip removed; the Vertex warning call removed from `build()`.

**Live run of the patched build** (`5be8d02f1`, real Gemini API, `gemini-3.5-flash`, an agent with the calculator and datetime built-in tools and a system prompt forcing tool use; the same driver script run against both builds):

| Scenario                                                           | Patched `5be8d02f1`                                                          | Unpatched 6.4.0 release image |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------- |
| Non-streaming turn 1 (tool call)                                   | correct, READY                                                               | ERROR                         |
| Non-streaming turn 2 (history holds a tool turn)                   | correct, READY                                                               | ERROR                         |
| Non-streaming turn 3 (two tool calls in one turn)                  | correct, READY                                                               | ERROR                         |
| Streaming SSE tool turn                                            | correct, READY (`task_start`, `tool_call`, `token`, `task_complete`, `done`) | ERROR (`task_failed`)         |
| HITL `requireApproval: ["builtin:*"]` — pause before the tool runs | AWAITING\_HUMAN                                                              | AWAITING\_HUMAN               |
| HITL — resume with APPROVED                                        | correct, READY                                                               | ERROR                         |
| **Total**                                                          | **6/6**                                                                      | **1/6**                       |

The unpatched container logged 33 Gemini 400s, "Function call is missing a thought\_signature in functionCall parts". Not covered live: the degraded resume path (transcript over its cap), which is exercised only by the unit tests.

### Provider survey — the same defect class elsewhere

| Provider                         | Verdict                                                                                                                                                                                                                                                                                                                                                                         |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gemini`                         | was live — fixed here                                                                                                                                                                                                                                                                                                                                                           |
| `gemini-vertex`                  | **live, not fixable in EDDI.** `langchain4j-vertex-ai-gemini:1.20.0-beta30` has no `thought`/`thinking`/`signature` anywhere, and the `Part` protobuf it uses (`proto-google-cloud-vertexai-v1:1.27.0`, via `google-cloud-vertexai`) has no `thought_signature` field. Needs upstream changes; `build()` now warns for Gemini 3.x ids, bare or fully qualified, naming `gemini` |
| Anthropic, Bedrock               | **latent.** Signed thinking blocks, modelled by langchain4j under the same `thinking_signature` key, but no config key enables extended thinking, so no signed block is ever returned. Set `returnThinking(true)` when that is exposed; fixes 2–4 already apply                                                                                                                 |
| OpenAI, Azure OpenAI             | fine — Chat Completions has no opaque reasoning token; EDDI never uses the Responses API                                                                                                                                                                                                                                                                                        |
| Mistral, Ollama                  | fine — plaintext thinking, no signature                                                                                                                                                                                                                                                                                                                                         |
| HuggingFace, Jlama, Oracle GenAI | fine — no reasoning concept in the modules                                                                                                                                                                                                                                                                                                                                      |

### Files

`GeminiLanguageModelBuilder`, `ModelParameterValues`, `VertexGeminiLanguageModelBuilder`, `ToolApprovalGateSupport`, `ToolLoopResumer` (`gatingExchange`), `ChatTranscriptCodec`, `PendingToolCallBatch`, `ConversationMemoryUtilities` (the strip and the sanitizer); `docs/langchain.md`, `docs/hitl.md`; tests `GeminiThoughtSignatureTest`, `ToolApprovalGateSupportNormalizeTest`, `ToolLoopResumerGatingMessageTest`, `VertexGeminiVersionWarningTest`, plus additions to `ModelParameterValuesTest` and `ConversationMemoryUtilitiesHitlTest`. `AgentOrchestratorCoverageTest`'s mixed-batch fixture was given the runtime message order for accuracy; it guards nothing new.

`LanguageModelBuildersTest` shows 13 sandbox-only errors (`Unable to establish loopback connection` from `JdkHttpClient`) — identical on a clean tree.

## 🐛 fix(operator): the Platform Operator's self-URL, its replacement, and its error message (2026-09-18)

**Repo:** EDDI (`fix/operator-self-url`) — backend and `ui/manager/`, in one branch so the two halves are tested together

The Platform Operator was dead on arrival on a customer deployment. The agent deployed, reported "Gate verified", and then failed **every** tool call — while telling the admin that "the documentation service is currently unavailable" and that the refused connection "indicates a problem with the platform's internal services". EDDI's health was fine the whole time.

All 22 of the operator's api-call resources carried `targetServerUrl: http://localhost:7080` — the origin the **browser** had used, over an SSH tunnel, in front of a container listening on `:7070`. That address means nothing inside the container. (`:7070` is confirmed by the container's own health check, `curl -f http://localhost:7070/q/health`, which passes from inside.) Three separate defects, each of which made the other two harder to find.

### Defect 1 — the self-URL came from the browser

`provisionOperator` sent `apiBaseUrl: window.location.origin`. That is right only when nothing sits between the browser and EDDI; any tunnel, published-port remap, container port remap or reverse proxy on another port produces a dead operator, silently.

**Where the fix belongs: the backend has to supply the address, and it did not.** The browser cannot know it, and neither can any other client. Three mechanisms were weighed:

| Option                                                                              | Verdict                                                                                                                         |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Manager offers an explicit, pre-filled override                                     | Necessary but **not sufficient alone** — pre-filled from what?                                                                  |
| The operator's calls use a placeholder resolved server-side (`${self:baseUrl}`)     | Rejected: a new resolver in the hot path of every api call, and it hides the value that the incident was diagnosed by *reading* |
| **EDDI exposes its own base URL; the Manager asks and pre-fills an editable field** | **Chosen**                                                                                                                      |

So: **`SelfUrlResolver`** (new, `ai.labs.eddi.engine.security`) answers `eddi.self.base-url` when a deployment sets it, otherwise `http://127.0.0.1:${quarkus.http.port}` — the port read from config, not assumed, and the same address `RestInterfaceFactory` has always used for EDDI's internal loopback hop. Loopback is correct behind a reverse proxy and on a remapped port *because* it ignores both: a process reaches itself without going back out through whatever is in front of it. The override exists for the cases where loopback genuinely is wrong — in-process TLS, a service name a mesh requires. It is served by **`GET /administration/operator/self-url`** (`IRestOperatorMetrics`, `eddi-admin`), a GET with no arguments so the answer cannot depend on a `Host` header or an `X-Forwarded-*` chain a proxy rewrites.

**The part that would have turned one failure into another.** On an OIDC-protected deployment the operator's tools authenticate with `${caller:token}`, and `CallerIdentityResolver` releases that token **same-origin only**. Pointing the tools at EDDI's own address makes them cross-origin by that rule — so the fix for defect 1 would have produced a 401 on every call instead of a connect failure. `CallerIdentityResolver` now also releases the token when the target is *this very process* (`SelfUrlResolver.isSelf`). That is the same argument `LoopbackCallerAuthFilter` already makes for the internal hop: the token is handed back to the process that issued the request it came from. It is a **narrower** release than same-origin, not a wider one — `SelfUrlResolver`'s value comes from deployment configuration only, never from an agent config, a conversation or a request header, so no config can nominate itself. Counted under its own `resolved_self` outcome tag rather than folded into `resolved`.

Manager side: `OperatorConfig` gains `apiBaseUrl` (optional — a config blob written before this field has no key at all), `resolveOperatorApiBaseUrl` decides it (explicit value → backend answer → browser origin *with a warning*, only on a backend that 404s the new endpoint), and `provisionOperator` now **throws** rather than falling back: the silent fallback is the defect. The value is persisted, not just sent, so the operator screen can show the address the live tools call — the field the incident turned on was, until now, nowhere on screen.

### Defect 2 — Reconfigure left the old operator deployed

Changing only the model produced **two** operators on staging, both `READY`, with the UI silently talking to the new one. That cost real debugging time: the first repair was applied to the agent that was no longer in use, and the symptom did not move.

Not a platform constraint — the two bots on that instance are versioned in place, and `setup-api` creating a new agent id is a Manager consequence, not an EDDI one. The Manager already *tried* to retire the predecessor; it failed for two reasons, both fixed:

1. `removeSupersededAgent` undeployed **without** `endAllActiveConversations`. The backend answers 409 while an agent still has active conversations, and the superseded operator's active conversation is almost always the admin's own operator chat — on the very screen the Reconfigure button lives on. So having *used* the operator was enough to make its replacement leave it deployed. `deactivateOperator` and `resetOperator` already pass the flag for exactly this reason.
2. The caller wrapped the whole retirement in a bare `catch {}`. A failed retirement now travels back as `ActivationOutcome.supersededWarning`, naming **both** agent ids, and the operator page shows it as a persistent destructive banner — not a toast, because the admin needs to still be able to read it when they start wondering why the operator is behaving oddly. (`activationError` was no use: it renders inside the activation form, which is already closed by then.)

The replacement is also explicit now rather than implied: the status panel shows the agent id and the base URL it calls, and the pre-save warning says the current agent is undeployed and deleted, names it, and says this screen will address the new one from then on.

### Defect 3 — the failure message pointed at the wrong thing

A transport failure surfaced to the model as the bare exception message — `"Connection refused"` and nothing else. The model has no way to tell an unreachable target from a broken dependency, so it guessed, and its guess sent the admin to check EDDI's health.

`HttpCallToolsProvider.describeToolFailure` now recognises a connect-class failure (`ConnectException`, `UnknownHostException`, `UnresolvedAddressException`, `NoRouteToHostException`, connect timeouts — matched by type through the whole cause chain, with a message-text fallback for clients that flatten it), names the method and the address that was tried, states that this is a network failure reaching that address and **not** a fault in the service behind it, and says the configured base URL must be one the EDDI server can reach rather than one a browser uses. Every other failure keeps its own message: telling the model to suspect the base URL on a 400 would misdirect in the other direction.

**What may travel in that string.** It reaches the model and so, in paraphrase, the chat surface. The URL goes in — it is the whole diagnostic value and it is configuration an admin can already read. Headers do not. Neither does anything vault-resolved, which is why the address is built from `targetServerUrl` plus the **configured** path rather than the fully-resolved request URI: `ApiCallExecutor` resolves `${vault:…}` and global-variable references into that URI, so it can legitimately hold a secret. The result is passed through `SecretRedactionFilter` as a belt-and-braces measure against a base URL that embeds credentials.

The Manager's activation canary gained the matching diagnosis: a connect-shaped tool result now reports the configured base URL and says outright that this is not an EDDI outage. It is checked *after* the auth check and allowed to win — an unreachable address is the more actionable of the two, and a 401 cannot have happened if nothing connected.

### Files

**Backend:** `engine/security/SelfUrlResolver.java` (new), `engine/api/model/OperatorSelfUrl.java` (new), `engine/api/IRestOperatorMetrics.java`, `engine/rest/RestOperatorMetrics.java`, `engine/security/CallerIdentityResolver.java`, `modules/llm/impl/HttpCallToolsProvider.java`, `resources/application.properties` (documents `eddi.self.base-url`). Tests: `SelfUrlResolverTest`, `CallerIdentitySelfOriginTest`, `HttpCallToolsProviderFailureMessageTest`, `RestOperatorMetricsTest`.

**Manager (`ui/manager/src/`):** `lib/api/operator.ts`, `hooks/use-operator.ts`, `components/operator/operator-activation.tsx`, `components/operator/operator-status.tsx`, `pages/operator.tsx`, `test/mocks/handlers.ts` (one handler for the new endpoint), `test/mocks/openapi-operations.json` (regenerated from this branch's own spec with `OPENAPI_FILE=../../target/openapi/openapi.json npm run openapi:refresh` — one line added, `GET /administration/operator/self-url`; no contract-test exemption needed), all 11 locales, plus tests in `lib/api/__tests__/operator.test.ts`, `hooks/__tests__/use-operator-supersede.test.tsx` (new), `components/operator/__tests__/operator-activation.test.tsx`, `pages/__tests__/operator.test.tsx`, `pages/__tests__/operator-superseded.test.tsx` (new).

**How the halves line up.** The Manager calls `GET /administration/operator/self-url` and reads `{ baseUrl, source }` — the `OperatorSelfUrl` record exactly, with `baseUrl` typed nullable for the `unresolved` case. A 404 (a backend older than the endpoint) reads as "cannot tell" and falls back, with a warning; every other error, a 401/403 included, propagates rather than being guessed past. The MSW handler answers the loopback shape the backend produces by default, and the snapshot the contract test checks it against was generated from this branch, so a drift in either direction fails.

### Mutation checks

Every fix was reverted and the pinning test confirmed red.

| Mutation                                               | Test that failed                                                          |
| ------------------------------------------------------ | ------------------------------------------------------------------------- |
| `provisionOperator` back to `currentOrigin()`          | "targets the address EDDI can reach ITSELF at, not the browser's origin"  |
| `resolveOperatorApiBaseUrl` prefers the browser origin | the five `resolveOperatorApiBaseUrl` cases                                |
| `requireApiBaseUrl` falls back to the origin           | "refuses to provision without a resolved base URL"                        |
| `CallerIdentityResolver`'s `isSelf` branch removed     | `resolvesForSelfWhenCallerOriginDiffers`                                  |
| `SelfUrlResolver` hardcodes 7070                       | `followsNonDefaultPort`                                                   |
| `SelfUrlResolver` ignores the configured override      | the `eddi.self.base-url` cases                                            |
| `selfUrl()` answers a constant                         | `selfUrlAnswersTheDeploymentsOwnAddress`                                  |
| `describeToolFailure` back to `e.getMessage()`         | `namesTheAddressThroughTheExecutor`                                       |
| retirement drops `endAllActiveConversations`           | "ends the superseded operator's conversations so its undeploy cannot 409" |
| `supersededWarning` forced to null                     | "reports a failed retirement instead of swallowing it"                    |
| canary drops the connection diagnosis                  | the three connect-diagnosis canary cases                                  |

One of those mutations initially **survived**, and the fix for that is worth carrying forward: the first cut of `HttpCallToolsProviderFailureMessageTest` only called `describeToolFailure` directly, so reverting the *catch clause* to `e.getMessage()` left the helper intact and all 11 cases green. The test now also drives the real executor lambda `discover` builds (`Wiring`, with a mocked `IApiCallExecutor` that throws), which is what actually pins what a failing tool call hands back to the model. A helper-level test proves the helper; only the call site proves the behaviour.

One process note, because it cost half an hour twice: a mutation check must **not** be undone with `git checkout -- <file>` while the fix is unstaged — that discards the fix along with the mutation. Copy the file aside and copy it back. And copy it back with `shutil.copy` rather than `copy2`: `copy2` preserves the backup's mtime, so the restored source looks *older* than the `.class` Maven compiled from the mutated one, incremental compilation skips it, and every later run keeps testing the mutation. That reads exactly like a real regression.

### Independent review, and what changed because of it

A fresh reviewer that had not seen this work went through both halves adversarially. Its security pass on the `CallerIdentityResolver` change found the value provenance sound — `SelfUrlResolver` reads only `eddi.self.base-url` and `quarkus.http.port` at construction; nothing writes config at runtime; no agent config, global variable, vault reference, `Host` or `X-Forwarded-*` header reaches it — and the origin check sound against look-alikes: `OriginMatcher` compares parsed `scheme://host:port` with no DNS, so `https://`, `localhost`, `127.1`, `0.0.0.0`, `[::1]`, `[::ffff:127.0.0.1]`, another port and `http://127.0.0.1:7070@evil.example` are all refused, and the check runs on the final URI after template, global-variable and vault resolution, so nothing can alter the target after it. Every finding was addressed:

| #  | Finding                                                                                                                                                                                                               | Resolution                                                                                                                                                                                          |
| -- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1  | `describeToolFailure` claimed to redact URL credentials but `SecretRedactionFilter` only knows secret *shapes*; a plain `admin:hunter2@` went through, and the test passed only because it used an `sk-ant-` password | `stripUserInfo` removes `user:pass@` structurally from every URL in the message; new test with a plain password, mutation-checked                                                                   |
| 2  | `AGENTS.md`, `docs/httpcalls.md`, `docs/mcp-server.md` still stated same-origin as absolute                                                                                                                           | All three updated                                                                                                                                                                                   |
| 3  | "Narrower than same-origin" was wrong: the self address bypasses the reverse proxy, so the reachable endpoints are what EDDI authorizes, not what the proxy also permits                                              | Claim corrected in code and docs; new `eddi.caller-identity.self-release.enabled` (default true) for a deployment that relies on proxy rules too                                                    |
| 4  | With SSRF protection on, loopback is refused and the model got a raw "internal/local addresses" message                                                                                                               | Not exempted — that would weaken SSRF protection for every agent. Documented instead, and the tool result now names SSRF protection and the remedy (`eddi.self.base-url` to a non-loopback address) |
| 5  | An identity with no captured origin became releasable to self (fail-closed to fail-open)                                                                                                                              | Kept fail-closed: `origin == null` never gets the self release; test added                                                                                                                          |
| 6  | The page banner for a failed retirement was untested                                                                                                                                                                  | `operator-superseded.test.tsx` drives the page's own wiring                                                                                                                                         |
| 7  | A stored address wins over the server's on reconfigure, and the "derived from HTTP port" note could sit under a different value                                                                                       | The form now says when the field differs from the server's current answer; the loopback note shows only when they match                                                                             |
| 8  | An admin-typed trailing slash became `//path` in every tool                                                                                                                                                           | `normalizeBaseUrl` strips it before provisioning                                                                                                                                                    |
| 9  | Netty's connect timeout (a `ConnectException` subclass) read as "refused"; `SocketTimeoutException` also covers READ timeouts, where the service *is* at fault; the "unresolved" text fallback was too broad          | Netty timeout matched first by name; `SocketTimeoutException` dropped; fallback narrowed to "unresolved address"; tests for both                                                                    |
| 10 | A predecessor with no recorded version was skipped silently                                                                                                                                                           | Reported through `supersededWarning` like any other failed retirement                                                                                                                               |
| 11 | `quarkus.http.port=0` (random) answered a confident `:7070`                                                                                                                                                           | Now `source: unresolved`, `baseUrl: null`, `isSelf` false for everything; the Manager treats it as "cannot tell"                                                                                    |
| 12 | Some operator strings are not i18n keys                                                                                                                                                                               | Declined: matches every existing `toolError` string in the file; `i18n:check` is green                                                                                                              |
| 13 | `headersOnlyStillHolds` tested code this change never touched; the canary's "agent description" test used text none of the regexes matched                                                                            | The first removed; the canary match is now anchored to the `{"error": …}` failure shape and the test uses a description containing the exact trigger phrases                                        |

Every review fix was mutation-checked the same way: R1–R7 in the backend (null origin, the switch, userinfo, the SSRF branch, Netty timeout, read timeout, random port) and U1–U9 in `ui/manager` — each reverted, each failing its named test.

A second fresh review of the final branch, with the Manager in `ui/manager/`, found the security pass sound again (provenance, look-alikes, the URI checked being the URI sent, null-origin and opt-out wiring, no open redirect that would carry the header) and nine smaller findings, all addressed: `docs/hitl.md` still carried the retracted "narrower" claim; the form promised a browser-origin fallback on a 403/500 that activation would not perform (now a distinct notice, and `retry: false` so it appears at once); an `unresolved` answer fell back to the browser origin — the original defect's value — and now refuses with a clear message, the fallback reserved for a genuine 404; `stripUserInfo` stopped at the first `@`, leaking the tail of an un-encoded `p@ss` password; client validation accepted a path or trailing text; the regression test resolved through the helper's preset address instead of the backend (now `apiBaseUrl: null`); a note on `quarkus.http.test-port`; the notice icon; and an `sk-ant-` test fixture replaced with `sk-test-`. Mutation-checked: V1–V4, each failing its named test.

### Noted, not fixed

* **`HttpCallToolsProvider`'s sibling path.** `ApiCallsTask` (rule-based httpcalls, no LLM) has the same bare-message behaviour. Left alone: there is no model there to misdiagnose for a human, and the diagnostic belongs where a model paraphrases the error.
* **`activationError` is unreachable after a successful activation.** It renders only inside the activation form, which the success handler closes — so the existing write-probe failure path (`setActivationError(message)` in `pages/operator.tsx`) is visible only as its toast. Worked around here with a dedicated banner rather than fixed for both.
* **`secret-key-picker-reference-only.test.tsx` flakes under full-suite load** — it passed in isolation and on a clean tree, and passed on the full suite the second time. Timing, not a regression from this work.

## 🔗 feat(rag): knowledge-base sources and the ingestion pipeline (2026-09-17)

**Repo:** EDDI (`feat/rag-ingestion-pipeline`)

### Why sources live on the knowledge base

`RagConfiguration` gains `sources[]` rather than ingestion sources becoming a 13th resource type. The vector store is keyed by the knowledge base, so a source that could exist independently of one has to name its target by string — and that is exactly how the draft in PR #529 came to key ingestion on the **source's** name (`kbId = sourceConfig.name()`) while `RagContextProvider` keys retrieval on the **knowledge base's**. Crawled content went into one pgvector table and every query read another. The run reported success; the agent retrieved nothing. No test caught it because none performed a retrieval after an ingest. Ownership removes the possibility rather than documenting it.

### The pipeline

`IngestionPipeline` runs crawl → convert → compare → embed per document, then reconciles deletions:

* **Re-ingesting replaces.** A document's chunks are removed by `documentId` metadata before its new ones are added. The draft called `EmbeddingStoreIngestor.ingest`, which only appends and never removed anything, so a page edited weekly left a year of stale versions retrievable beside the current one. Where a store's driver cannot delete by metadata, the run says so (`replaceUnsupported`) instead of quietly accumulating.
* **A document is recorded only after its vectors are stored.** The draft committed the content hash while *deciding* whether to ingest, with embedding afterwards inside a `catch` that only logged — so a single 429 marked a page done forever.
* **Only a crawl that covered the source may conclude anything is gone.** A run stopped by its page cap, time budget or segment budget sets `tombstoningSkipped` and deletes nothing.
* **Tombstoned documents lose their vectors.** In the draft, "stale detection" flipped a flag in a side table nothing consulted at retrieval time, so a deleted page kept answering questions forever.
* Segments carry `documentId`, `url`, `title`, `sourceName`, `runId` and `ingestedAt`, so an answer can cite its source. Counts are the segments actually written, not `markdown.length() / chunkSize`.
* `maxSegmentsPerRun` is the cost ceiling — exact without a pricing table; set `costPerThousandSegments` to have runs report dollars too. `PREVIEW` mode crawls and reports what would change without embedding or recording anything.

### Tests

**25 pipeline tests, 23 more for the in-memory state store.** The test double implements the same `IngestionStateStoreContract` as MongoDB and PostgreSQL, so it cannot quietly behave differently from production — the failure mode that let the draft's two stores drift apart.

Mutation-checked against all four headline defects: keying the store on the source, appending instead of replacing, recording the hash before embedding, and tombstoning after a partial crawl each fail between one and seven tests.

### Note on the branch

This branch is stacked: it contains the converter, state store and crawler commits because the pipeline needs all three. Merge those three first, or review this as a stack.

## 🧩 fix(rag): register `ai.labs.rag` so RAG workflow steps can be deployed (2026-09-17)

**Repo:** EDDI (`feat/rag-workflow-extension`)

### Why

A workflow step of type `eddi://ai.labs.rag` could not be deployed at all. `WorkflowStoreClientLibrary` resolves a step by `URI.getHost()` against the `@LifecycleExtensions` map and throws `UnrecognizedExtensionException` when the key is absent — and no module ever registered `ai.labs.rag`. The nine registered types were parser, behavior/rules, property, httpcalls/apicalls, output, llm, mcpcalls and templating.

So the two knowledge-base options documented in [`rag.md`](/agent-configuration/rag.md) were undeployable; only `httpCallRag` worked end to end. `RestWorkflowStepStore` builds the Manager's step chooser from the same map, so the real backend never offered the step either — the Manager's MSW fixture hard-codes it, which is why its UI suite stayed green.

Nothing caught this: `RagContextProvider` discovers RAG steps by reading the workflow document directly (`WorkflowTraversal`), and its tests build `WorkflowConfiguration` objects by hand, so no test ever traversed the deploy path.

### What changed

* **`modules/rag/RagTask.java`** (new) — the config-carrier task. `execute` is a deliberate no-op: retrieval stays in `RagContextProvider` inside the LLM task (the Phase 8c decision stands), because that is where the user's query is known. `configure` resolves the referenced `RagConfiguration`, so a broken knowledge-base binding fails when the workflow is deployed instead of silently returning no context on the first conversation.
* **`modules/rag/bootstrap/RagModule.java`** (new) — registers the task under `ai.labs.rag`, matching the host of the documented step URI.

`getType()` returns the stage name `"rag"`, not the `eddi://` URI. Per the `ILifecycleTask` contract the type is a lifecycle stage identifier used for ordering and partial-execution filters; the URI form matches no filter. (The draft this was salvaged from returned the URI — see Decision Log.)

### Tests

* **`RagTaskTest`** (10) — id/type contract, `configure` happy path, missing/blank/null/malformed URI, `ServiceException` wrapping, no-op `execute`, descriptor shape.
* **`RagWorkflowDeploymentTest`** (6) — deploys a real workflow containing a RAG step through `WorkflowStoreClientLibrary`, including the negative case that pins the bug: without the registration the whole workflow is rejected.
* **`RagModuleTest`** (3) — registration key equals `URI.create("eddi://ai.labs.rag").getHost()`.
* **`integration/RagWorkflowExtensionIT`** — asserts against live CDI wiring via `RestWorkflowStepStore` (the bean that feeds the Manager's chooser) that the RAG step is offered, and that every documented workflow step type resolves. This is the test that would have caught the original bug; it is an `IT` because the repo runs every `@QuarkusTest` in the integration job.

Mutation-checked: reverting `getType()` to the URI and the registration key to a wrong value fails 4 of the tests.

### Notes

This is the first of six PRs salvaging the scheduled RAG ingestion work from PR #529, which has been stale and conflicting since 2026-07-02. This piece is independent of that feature — main needs it either way.

## 🛠️ fix(migration): four first-boot defects found upgrading a real 5.5.1 database (2026-09-17)

**Repo:** EDDI (`fix/first-boot-migration-order`)

Found by rehearsing an upgrade of a customer deployment's **staging** EDDI 5.5.1 database (MongoDB Atlas, 3012 documents, 7 agents, 195 conversations) to 6.4.0 against a verified restore of the production-like dump. Four defects fire on the first boot against a 5.x database; two of them destroy data. All four are fixed here with tests, including three that drive a real MongoDB through Testcontainers.

### What changed

* **`TemplateSyntaxMigrator.migrateStringConcat` crashed on a `+` inside a string literal.** It split the concat expression with `split("\\s*\\+\\s*")`, which cuts literals apart: a literal `'+'` became two lone quote characters, a lone quote both starts and ends with a quote so it was taken for a quoted literal, and stripping its delimiters was `substring(1, 0)` — `StringIndexOutOfBoundsException: Range [1, 0) out of bounds for length 1`. A new `splitOnConcatOperator` splits only outside quotes, and `isStringLiteral` requires length ≥ 2 and matching delimiters. The real trigger on staging was a single `httpcalls` config holding a template whose three concatenated literals render as another template expression.
* **`V6QuteMigration.migrateCollection` had no per-document isolation**, so that one malformed template aborted the Thymeleaf→Qute conversion for *every* config in the database, logging only "will retry on next startup" — where it threw again. Each document now migrates in its own try/catch, failures are logged with collection and id, and the migration is **not** marked complete while any document failed, so it retries once the data is fixed. `migrateCollection` returns a `CollectionResult(migrated, failed)`.
* **`MongoDeploymentStorage`'s unique `(environment, agentId, agentVersion)` index destroyed deployment rows on a pre-rename database.** EDDI 5 wrote `botId`/`botVersion`; Mongo indexes the absent `agentId` as null, so an unrestricted unique index read all 113 staging rows as duplicates of one another, `createIndex` failed with E11000, and the recovery path `removeDuplicateDeploymentRows()` kept one row for the whole collection and deleted 112. The index is now partial on `agentId`/`agentVersion` existing, and the dedupe pipeline `$match`es only rows that carry the key.
* **The `@Scheduled(every = "10s", delayed = "10s")`** **`checkDeployments()` sweep ran before the rename migration and deleted deployments.** On a first boot against a 5.x database the agent configs are still in `bots`; `agents` does not exist until `V6RenameMigration` creates it, so `isAgentConfigMissing` returned true for every deployed agent and the sweep called `deleteDeploymentInfo` on each. Observed live: the deployment rows of both deployed agents deleted. The sweep now returns early while `V6RenameMigration.isPending()`.

### Design decisions

* **The sweep gate asks the migration, it does not track a flag.** The first cut set a `volatile boolean startupMigrationsAttempted` at the end of `autoDeployAgents()`. Two problems: nothing set it if anything above it threw (parking the sweep, and with it all deployment, forever), and it read "migrations attempted" as "collections renamed" — so a rename migration that *failed* released the sweep to delete the rows anyway. `V6RenameMigration.isPending()` is the actual precondition: `enabled && no completion entry in the migration log`, latched once complete so a ten-second schedule does not re-read the log forever, and fail-safe (an unreadable log counts as pending). Disabled is deliberately *not* pending — the property defaults to false, so "no completion entry" is the permanent state of every installation that never needed the migration, and reading that as pending would park the sweep on every normal EDDI 6 database. It also made the fix testable without rewriting the \~25 existing `checkDeployments()` tests, which call it directly on a freshly constructed object.
* **A conflicting index is dropped and rebuilt.** Mongo does not re-shape an existing index: adding `partialFilterExpression` to a key pattern that already carries the non-partial unique index is refused, not a no-op. Every installation already running 6.x would otherwise have kept the destructive index while logging something that reads like a warning about duplicate rows. On either conflict code the index actually sitting on the deployment key is looked up and dropped **by name**; if none does, the conflict is with someone else's index and is left alone. E11000 still goes to the dedupe-and-retry path, because there the *rows* are wrong and dropping the index would throw the constraint away instead of fixing them.
* **The partial filter uses `$exists`, not a null check**, so a row that legitimately carries a null `agentVersion` stays inside the uniqueness constraint. Only rows missing the field entirely — i.e. pre-rename rows — fall out of the index.
* **Not marking the Qute migration complete on a failure re-scans on every boot.** That is accepted: `TEMPLATE_COLLECTIONS` is four config collections plus their `.history` counterparts, the scan is cheap, and a migrated document contains no Thymeleaf syntax so nothing is rewritten twice. Shipping a half-migrated database silently is the worse trade. A collection that cannot be counted is a failure too, with one exception: `NamespaceNotFound` (26). Only some of these names exist on any given database and some driver versions answer `estimatedDocumentCount` on a missing namespace with that error rather than zero, so counting it would leave the migration permanently incomplete on a database with nothing to migrate; any other count failure means a collection nobody has read.
* **An empty part of a concat expression is now skipped rather than rendered as `{}`.** An empty operand only arises from a leading, trailing or doubled `+`, i.e. from an expression that was already malformed; `{}` is a broken Qute expression where nothing at all is a dropped empty operand.

### Review follow-up (PR #781)

Copilot found a real gap in the first version of the splitter: it left quote mode at the *first* matching quote character, escaped or not, so a valid OGNL literal such as `'it\'s + here'` ended at the escaped apostrophe and the `+` after it was read as an operator — cutting the literal in half again, just for a rarer input. A backslash now escapes the next character while inside a literal.

Stripping the delimiters also reduces `\'`, `\"` and `\\` to the character they stood for, because the conversion inlines the literal's text verbatim and Thymeleaf renders `'it\'s'` as `it's` — leaving the backslash in would put it on the screen. The other OGNL escapes (`\t`, `\n`, …) are deliberately left exactly as they are: a Windows path in a config is the likelier intent than a control character, and guessing wrong there rewrites config content rather than merely failing to tidy it.

CodeRabbit then found that the `catch` around `estimatedDocumentCount()` was half-right in the other direction: keeping the missing-collection case out of the failure count also swallowed authorization errors, timeouts and server errors, so `runIfNeeded()` saw zero failures and recorded completion over a collection it had never read — the same silent half-migration the per-document guard exists to prevent. Only `NamespaceNotFound` (26) now counts as "nothing to migrate here"; anything else counts as a failure and keeps the migration incomplete. A pre-existing test (`runIfNeeded_collectionsNotExist`) asserted the old behaviour on a false premise — `getCollection` does not contact the server, so it never fails merely because a collection is absent — and now asserts the corrected contract under the name `runIfNeeded_collectionAccessFailureBlocksCompletion`.

A final independent review found five more things; all are fixed here.

* **A v5 database with two deployment rows that become one v6 key never finished migrating, and so never deployed an agent again.** `ENVIRONMENT_REWRITES` maps both `unrestricted` and `restricted` to `production`, and v5's own check-then-act upsert wrote same-environment duplicates. The unique index `MongoDeploymentStorage` builds at construction already exists when the migration runs, so the second row's write failed E11000 on every boot — the first row already rewritten, the second never could be — and with the sweep waiting on the migration, nothing deployed. Before this PR the dedupe deleted rows but boot completed; the PR had turned lossy-but-booting into never-deploying. A collision is now resolved with the store's own rule: one row per key, the newest `_id` kept, so every node picks the same survivor. `migrateEnvironments` also isolates documents: one that cannot be written is logged, the rest still go through, and the migration is left incomplete rather than aborted. Three Testcontainers tests cover both collision shapes and the no-collision control. The staging rehearsal could not have caught this: it held no such pair.
* **A pre-check on `migrateEnvironments` was removed.** An earlier commit on this branch added one to skip a clean collection, motivated by the staging measurement: the startup migrations took 24 minutes, \~20 of them this pass on `conversationmemories` (195 documents averaging 410 KB; a read-only `mongodump` of the collection took 14 minutes on the same cluster). It could not help. Two of its three conditions were server-side counts, but the third — a legacy URI nested at arbitrary depth — has no filter form, and a sampled version was rejected because a miss is permanent once the migration records completion; an exhaustive one reads the whole collection, which is the entire cost. Clean collection: one read either way; dirty: two counts plus the same pass. It was net zero at best and the PR described it as a speedup. Removing the 20 minutes needs the rewrite moved server-side (`updateMany` with `$rename`/`$set`); that is follow-up work, not in this PR.
* **The index-conflict handling had the error codes wrong.** The review suggested handling 85 alone, and the real-server test showed why that is also wrong: an old non-partial index on the same key under the same auto-generated name comes back as `IndexKeySpecsConflict` (86) on current servers. Handling 85 only passed every mocked test and left the destructive index in place. Now either code triggers a lookup of the index on the deployment key, dropped by name; a same-named index on another key is not touched.
* **`/q/health/ready` reported ready with nothing deployed.** With the rename migration pending the sweep is parked, yet `autoDeployAgents()` still set readiness. It now stays not-ready, with an ERROR saying why; the migration only runs at startup, so that lasts until a restart after the cause is fixed. The "sweep parked" warning is logged once instead of every ten seconds.
* **This entry contradicted itself** on whether an unreadable collection counts as a failure; corrected above to match the code.

### Files

* `src/main/java/ai/labs/eddi/configs/migration/TemplateSyntaxMigrator.java`
* `src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java`
* `src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java` — new `isPending()`
* `src/main/java/ai/labs/eddi/configs/deployment/mongo/MongoDeploymentStorage.java`
* `src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java`
* `src/test/java/ai/labs/eddi/configs/migration/TemplateSyntaxMigratorTest.java`, `V6QuteMigrationTest.java`, `V6RenameMigrationTest.java` — the concat fixture fails with the original `StringIndexOutOfBoundsException` against the pre-fix splitter
* `src/test/java/ai/labs/eddi/configs/deployment/mongo/MongoDeploymentStorageTest.java` (mocked) and `src/test/java/ai/labs/eddi/datastore/mongo/MongoDeploymentStorageTest.java` (Testcontainers — pre-rename rows survive construction, a non-partial index is rebuilt as partial, and the dedupe spares pre-rename rows on a half-migrated collection)
* `src/test/java/ai/labs/eddi/datastore/mongo/V6RenameMigrationDeploymentsTest.java` (Testcontainers — deployment rows that collapse onto one v6 key)
* `src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementTest.java`, `AgentDeploymentManagementBranchTest.java`

### Verification

201 tests green in the selection (`TemplateSyntaxMigratorTest`, `V6QuteMigrationTest`, both `MongoDeploymentStorageTest`s, `V6RenameMigrationTest`, `V6RenameMigrationBranchTest`, `AgentDeploymentManagementTest`, `AgentDeploymentManagementBranchTest`) plus the repo-wide guards (`ImportStyleTest`, `DocumentationLinksTest`, `StrictBoundaryShippedConfigsTest`, `RuleSetStoreShippedRulesetsTest`, `BuildQualityGatesTest`, `ChangelogRotationTest`). Mutation-checked: reverting the literal-aware split, the partial filter, the dedupe `$match`, the index-conflict rebuild, the sweep gate or the "do not mark complete when a document failed" behaviour each makes a test fail.

### Review round: five findings, all in code that runs against a production database (2026-09-21)

* **A pass that could not read a collection counted as a pass with nothing to do.** `migrateAgentFields`, `migrateCollection`, `migrateDescriptors` and `migrateEnvironments` each opened with a bare `catch (Exception e) { return 0; }`, so an authorization error, a timeout or a step-down read exactly like "this collection does not exist": `runIfNeeded()` saw a clean total and wrote the completion log over a collection nobody had read, and because the migration runs once, those documents stayed in their v5 shape for good. Only `MongoCommandException` code 26 (`NamespaceNotFound`) is a skip now — the rule `V6QuteMigration` already applied — and everything else is counted, so the migration runs again on the next start. The four passes now return a shared `(migrated, failed)` result and `runIfNeeded()` aggregates `failed` across all of them, not only across the environment pass.
* **`saveDocument` swallowed every write failure and the callers counted the document anyway.** It returned `void` after catching everything, and it also declined silently to write an `_id` shape it cannot address. Either way the caller incremented `migrated`. It now reports whether the write happened and the callers count accordingly.
* **"Newest `_id` wins" was not sound across processes.** An ObjectId is `[4 bytes timestamp][5 bytes process-unique][3 bytes counter]` and `compareTo` compares them in that order, so for two ids created in the same second by different instances the larger id is as likely to be the older row — and this branch deletes the loser, which is a deployment status gone with nothing to recover it from. `strictlyNewer` now answers only where insertion order is established: a different second, or the same second and the same process, where the counter means what it looks like it means. Same second, different process is "cannot tell", and the collision is left unresolved — logged with both ids, counted as a failure, migration incomplete. A migration that stops and names two rows to reconcile is recoverable; a deleted row is not.
* **Index recovery could drop the wrong index, or the only good one.** `indexOnDeploymentKey()` returned the *first* index whose key pattern matched, and MongoDB allows two indexes on one key pattern when their names and options differ — the shape an installation lands in if the partial index was ever built beside the old unrestricted one. Every index on the deployment key is dropped now, then one partial index is rebuilt. And before anything is dropped, the index holding the name this one would be given is checked: if that name belongs to an index on a *different* key, nothing is dropped and the conflict is reported, because dropping ours would remove a working constraint and still not get past the name. The generated name is derived from `DEPLOYMENT_KEY_PATTERN`, not written out, so it cannot drift from the key the index is built on. Neither error code decides anything: splitting on 85 versus 86 was tried in an earlier round and broke against a real server, which reports the same key under the same name as 86.
* **A transient migration-log read failure left the instance not-ready for ever.** `setAgentsReadiness(true)` had exactly one call site, inside the startup path that runs once a second after boot. `isPending()` is deliberately fail-safe — a read that fails answers "pending", because answering "not pending" would let the sweep read every agent config as deleted and retire its deployment row — so one failed read in that second left readiness false for the life of the process while `checkDeployments()` deployed the agents ten seconds later and served them correctly. Readiness is now deferred rather than abandoned, and the first scheduled sweep that completes with the migration no longer pending grants it, exactly once. `E.D.D.I is ready!` moved to that same point: it used to be logged from a second `isPending()` call after the lambda, so a read that failed in one and succeeded in the other logged "ready" against an instance whose readiness flag was false.
* **A `}` inside a string literal defeated the Thymeleaf-to-Qute scan entirely.** `CONCAT_PATTERN` used `[^}]*?`, so `[[${a + '}' + b}]]` matched nowhere: the quote-aware splitter this PR added was never reached, the output patterns failed on it for the same reason, and the template was left in Thymeleaf syntax by a migration that runs once and then records itself complete. The expression is now located by a scan that tracks quote state and backslash escapes — the same state machine as the splitter, at the delimiter instead of at the operator. An unterminated expression is left exactly as it is rather than rewritten on a guess.

**Tests.** `V6RenameMigrationTest` gained `CollectionAccessTests` (NamespaceNotFound completes; an authorization failure and a timeout each keep the migration incomplete) and `StrictlyNewerTests`, whose third case asserts both that two processes inside one second are unordered *and* that full `ObjectId` ordering calls the lower-counter row the newer one — the defect, stated. `MongoDeploymentStorageTest` (mocked) gained "every index on the deployment key is dropped, not the first one listed" and "nothing is dropped when a different key holds the name ours would be given"; the Testcontainers test against a real MongoDB stays as it is, and is what proved an earlier 85-only fix wrong. `TemplateSyntaxMigratorTest` gained exact-output cases for a quoted `}`, a quoted `{`, an escaped quote before a brace, an unterminated expression and two expressions on one line. `AgentDeploymentManagementBranchTest` gained four readiness cases: granted by the sweep after a transient pending answer, granted once however many sweeps follow, never granted while the migration stays pending, and granted once on a normal boot.

## ⏱️ feat(rag): REST and scheduling for ingestion sources (2026-09-17)

**Repo:** EDDI (`feat/rag-ingestion-rest`)

### What this adds

The operable surface for the sources landed in the previous entry: four endpoints and a cron.

| Method   | Path                             | Access |
| -------- | -------------------------------- | ------ |
| `POST`   | `…/sources/{sourceId}/run`       | EDIT   |
| `POST`   | `…/sources/{sourceId}/preview`   | EDIT   |
| `GET`    | `…/sources/{sourceId}/runs`      | VIEW   |
| `DELETE` | `…/sources/{sourceId}/documents` | EDIT   |

**Running needs EDIT, not VIEW.** A published knowledge base grants VIEW to everyone by design, and a run rewrites what every agent using it retrieves — so gating a run on read access would let any editor point a source at any published knowledge base and poison it, on a schedule. The draft this replaces checked nothing at all. Preview is gated the same way: it writes nothing but still sends a visible amount of traffic to a third party's site.

Runs are async on a virtual thread (a crawl takes minutes; an HTTP request cannot wait for it), with a 409 rather than a second crawl when one is already in flight.

### Scheduling

A source with a `cron` gets a schedule carrying `ragIngestion` metadata, which `ScheduleFireExecutor` recognises as a fourth fast-path beside HITL timeouts, Dream consolidation and team cadences — the same shape of work, and the same reason: a maintenance job, not a conversation turn, that wants the cluster claim, lease, retry and fire log.

Schedules are named `rag-ingestion:{ragConfigId}:{sourceId}`, so syncing is delete-by-name then create: no scan and no orphans. The draft searched `readAllSchedules(1000)`; past a thousand schedules — HITL timeouts and Dream cycles each create one — it silently failed to find the row, then created a duplicate on update and left a schedule still crawling a deleted source on delete. A sync failure is logged as an ERROR naming the consequence rather than swallowed behind a 201.

Sources get a generated stable id on write. Addressing them by name would mean renaming a source orphaned everything it had ingested.

### Tests

15 for the service (schedule upsert, no-cron and disabled handling, surfaced store failures, scheduled fire against a deleted source or knowledge base, concurrent-run refusal, scoped purge) and 12 for the REST layer (access level per endpoint, refusals never reaching the service, 404s, 409, limit clamping). Four existing RAG REST tests were updated for the new constructor parameter.

## 🔒 fix(apicalls): configuration references work in templated HTTP-call fields; data-supplied ones are refused (2026-09-17)

**Repo:** EDDI (`fix/vault-references-in-templates`)

### Why

HTTP-call values are rendered by Qute before `${vars:…}`, `${vault:…}`, `${eddivault:…}`, `${caller:…}` and `${connection:…}` are resolved. Only `caller` had a pass-through namespace resolver, so every other reference in a URL, header, body or query parameter failed the call with "No namespace resolver found" — including `${connection:name}` headers, the documented way to use connections, and the vault references `docs/secrets-vault.md` lists as supported. Vault was kept failing on purpose, because a resolved body was stored unredacted.

The resolvers run on the *rendered* string, so a reference that conversation data put there was resolved too: a template substituting user input, a model reply or an API response sent the plaintext of any vault secret named in that data (grants are checked at deploy, not at read). That was independent of the namespace failure.

### What changed

* `ReferencePassThroughNamespaceResolver` (new base; `CallerNamespaceResolver` now extends it) and `ConfigReferenceNamespaceResolvers` with pass-through beans for `vault`, `eddivault`, `connection` and `vars`.
* `ConfigReferenceGuard` (new): after rendering and before resolution, every credential reference (`vault`, `eddivault`, `connection`, `caller`) must appear in that field's configuration template, or be the value of a property the template names that is exactly this agent's auto-vault reference (`${vault:<agentId>.<name>}`, under this conversation's own tenant). Otherwise the call is refused, naming the field. `${vars:…}` is not itself a credential reference, but a variable may hold one — see the second review fix below for how that is guarded.
* `ApiCallExecutor` records the vault plaintexts it substitutes (`BuiltRequest.resolvedSecrets`) and redacts them by value from the memory request record (`RequestRedactor.redactResolvedSecrets`), the approval preview (`ResolvedRequest.withoutResolvedSecrets`, fingerprint unchanged) and the request log lines.
* `docs/secrets-vault.md`: where references are resolved, and the rule above.

### Verification

* `ConfigReferenceNamespaceResolversTest`, `ConfigReferenceGuardTest` (10) and `ApiCallExecutorConfigReferenceTest` (15, incl. the nested `VariableIndirection` group) — the executor with the real Qute engine, not a templating stub — plus `RequestRedactorTest`'s new `SafeRequestLog` group (5), `ApiCallExecutorTest`, `ApiCallExecutorSecretContextTest`, `ApiCallExecutorBatchPrincipalPropagationTest`, `ResolvedRequestTest`, `CallerNamespaceResolverTest`. The apicalls, templating, secrets, connections, variables and properties suites are green (6353 tests), as are the repo-wide guards.
* Mutation-checked, one fix at a time: redacting after formatting instead of before, dropping the post-variable-expansion guard pass, accepting any tenant prefix on an auto-vault property, removing the fail-closed throw, and resolving a second time to build the value each fail a test.
* End to end on the packaged build (MongoDB, vault on, recording mock API): `${vars:}` in the target URL and `${vault:}` in a header and the body reach the API; an injected reference to another agent's secret is refused and never sent; neither secret appears in the response, the conversation read, the stored conversation or the server log (17/17). Run on the pre-merge branch; **not** re-run after the merge and the review fixes below, which add a fail-closed path a packaged run would exercise differently.

### Merged `main` (2026-09-20)

`main` had moved 28 commits on. Two conflicts:

* **`ApiCallExecutor.executeFireAndForgetCalls`** — `main` had moved batch request *building* onto the turn's own thread (each iteration getting its own copy of the template data) so an unsatisfiable reference fails the turn instead of a worker nobody reads; this branch had changed the same loop to carry `BuiltRequest` so the log line could be redacted. Resolved by keeping `main`'s structure and collecting `BuiltRequest` rather than `IRequest`. `main`'s `rejectExpiredSecretContext` calls in `buildRequest` auto-merged beside the guard calls and were kept on all four fields.
* **`docs/changelog.md`** — both sides added an entry at the top; both kept.

### Review fixes

Five findings from the PR review, all in the security path:

* **The log line is redacted before it is formatted.** `RequestWrapper.toString()` folds newlines and cuts the body at 150 characters, so redacting its *output* by exact value missed a secret carrying a newline (a PEM key) or straddling the cut. New `RequestRedactor.safeRequestLog(IRequest, Set)` reads the raw components from `IRequest.toMap()`, redacts those, and shortens afterwards; all three call sites use it.
* **`${vars:…}` can no longer smuggle a credential reference in.** A global variable is allowed to hold `${vault:…}` or `${connection:…}`, so a data-supplied `${vars:x}` passed the guard (not yet a credential reference) and became one after expansion. `ApiCallExecutor.resolveGuardedVariables` now guards again after expansion, against the configured template expanded the same way — a configured variable's reference is allowed, a data-supplied one is refused. Covered for URL, body, header and query parameter; in the path the reference never forms at all, because `pathSafeView` percent-encodes what data puts there (now asserted).
* **The auto-vault property exception no longer crosses tenants.** It accepted any `<tenant>/` prefix, so a user-supplied `${vault:victim/thisAgent.apiKey}` read another tenant's secret of that key name. The reference is now compared character-for-character against what `autoVaultSecret` would have written for that property under *this* conversation's tenant. Also removes the per-call `Pattern.compile`.
* **An unresolvable reference fails closed.** `SecretResolver.resolveValue` leaves a reference it cannot resolve in place, and the call went out carrying the literal `${vault:name}` as its credential. It now refuses, naming the field and the reference — the rule `SecretResolver.requireResolved` already applies to LLM client parameters.
* **One resolution per reference.** The bookkeeping pass and the substitution pass resolved separately, so a rotation between them put the new plaintext in the request while only the old one was in the redaction set — the value actually sent was the one that survived into memory, previews and logs. `resolveSecrets` now builds the string from the same resolutions it records.

Not fixed here, and why: the auto-vault exception was still a *shape* test rather than a provenance test. `Property` carried no "auto-vaulted" marker — a `scope: "secret"` instruction stores its vault reference with `scope: conversation`, exactly like any other property — so a real provenance check needed a marker on the persisted property model. What was left was bounded: the reference is derived from this agent and the property name the template reads, so data could not choose which secret is read, and it goes only to the endpoint the configuration names. **Closed on this branch by the 2026-09-20 entry above**, which adds that marker and makes the guard require it.

## 🗃️ feat(ingestion): document state and run history for RAG sources (2026-09-17)

**Repo:** EDDI (`feat/ingestion-state-store`)

### Why

Ingesting a source is a *reconciliation*, not an append: each run compares what the source offers now against what the knowledge base holds, and decides per document whether to skip, re-embed, or conclude it is gone. That needs durable state, and the shape of it is where the salvaged PR #529 went wrong — quietly, because a corrupted knowledge base has no stack trace. `IIngestionStateStore` is that state, designed so the draft's four failure modes are not expressible.

### The two rules the API enforces

* **A document is recorded only after its vectors are stored.** The draft's `shouldIngest(source, doc, content)` upserted the new hash while *deciding* whether to ingest, and embedding happened afterwards inside a `catch` that only logged. One 429 from the embedding provider therefore marked a page done forever: the hash matched on every later run, so it reported "unchanged" and was never embedded. Here `lookup` and `recordIngested` are separate calls and the Javadoc says which side of the embedding call each belongs on.
* **A document missing from one run is not a deleted document.** The draft marked everything not seen in the current run as stale, unconditionally — so a site outage, a network blip, or simply hitting `maxPages` flagged the remainder of the corpus. `tombstoneMissing` counts *consecutive* misses and only tombstones at a threshold, and callers are told not to call it for a failed run at all.

### What it stores

Per (source, document): content hash, ETag and Last-Modified for conditional fetching, first/last ingested timestamps, last run id, consecutive miss count, tombstone flag. Per run: status, timings, the seen/ingested/unchanged/failed/tombstoned counters, segments stored, cost in dollars, and the error — so a failure is visible in the Manager rather than only in a log line.

`startRun` returns empty when a run is already in flight, enforced by a **partial unique index** on `(source_id) WHERE status = 'RUNNING'` in both backends. This is what stops an operator clicking "run now" five times from starting five concurrent crawls into one knowledge base, and because the database enforces it, it holds across instances. `reapStaleRuns` releases a source whose run died with its process.

`ContentHashes.sha256` is a static utility rather than an interface method: the draft had each backend carry its own copy, two chances to drift, and a drift silently re-embeds an entire knowledge base.

### Tests

**One shared contract, run against both backends** — `IngestionStateStoreContract` is a JUnit interface with 23 cases, implemented by `MongoIngestionStateStoreTest` and `PostgresIngestionStateStoreTest` (Testcontainers). The draft's two stores had drifted apart — one overwrote the first-ingest timestamp on every call while the other preserved it — and nothing failed, because each was only tested against itself. A knowledge base that behaves differently depending on the operator's database choice is a support problem with no error message.

Plus 7 cases for `ContentHashes`, including a pinned published SHA-256 vector: if the hash ever changes, every deployed knowledge base re-embeds itself on the next run.

53 tests, all green on both backends. Mutation-checked: reverting `setOnInsert` to `set` and ignoring the miss threshold fails three of them.

### Next

The crawler, then the source config plus the pipeline that ties fetch → convert → this store → embed, with vector removal driven by the tombstone list.

## 🔎 fix(operator): let the Platform Operator inspect knowledge bases (2026-09-17)

**Repo:** EDDI (`fix/operator-rag-reads`)

### Why

An admin asked the Operator to check a RAG knowledge base. It answered that the tool `readRag` "was not found". That tool never existed: `tool-scopes.ts` named no `ragstore` path, so no RAG tool was ever generated — the model guessed a name by analogy with `readLlm` and the tool loop answered `Error: Tool 'readRag' not found` (`ToolLoopRunner:654`).

The guess was the symptom of an asymmetry. The Operator *can* read `docs/rag.md` (the docs endpoints are granted in both scopes), so it knew knowledge bases exist — while holding no tool for one and no sentence saying so. Knowing a feature exists with neither a tool nor a word about it is what produces an improvised tool call, the same failure `BODY_AUTHORING_NO_AGENT` already prevents for agents.

### What changed

* **`tool-scopes.ts`** — three reads added: `GET /ragstore/rags/descriptors`, `GET /ragstore/rags/{id}`, `GET /ragstore/rags/{id}/ingestion/{ingestionId}/status`. Descriptors are included because a KB is asked about by NAME; without the listing the by-id read is unreachable. New predicates `grantsKnowledgeBaseReads` / `grantsKnowledgeBaseAuthoring`.
* **`system-prompt.ts`** — a knowledge-base section, conditional on those reads, plus a derived "you cannot create, edit or ingest" sentence; `rag` added to the step-type list and the docs map.
* **`McpAdminTools`** — `read_resource` gained a `"rag"` case, so an MCP client can read a KB config too.

### Decisions

* **Not folded into `WORKFLOW_EXTENSION_STORES`.** That constant doubles as `WRITABLE_EXTENSION_STORES`; adding `ragstore/rags` there would grant PUT/POST as a side effect of wanting a read.
* **Reads only; `ingest` stays excluded**, as `planning/operator-write-scope-plan.md` §5 requires. The ingestion *status* read is what answers "did the documents land?" without a write.
* **No new exposure class.** RAG embedding and vector-store credentials are `${vault:...}` references resolved at runtime, exactly like `llmstore`, which was already granted.
* **The boundary sentence is derived, not asserted**, so allow-listing a RAG write later cannot leave the prompt claiming the opposite.

### Note

Existing operators keep their old tool set — tools are provisioned at activation, so an operator must be re-activated to gain these.

### Review round 1 (Copilot)

Four inline findings plus one *suppressed* comment (no thread — only visible in the review body), all acted on:

* **`ai.labs.rag` → `unknown` in `STEP_TYPE_TO_RESOURCE_TYPE`** — a real dead end: an MCP client following `list_agent_resources` into `read_resource` would have passed `unknown` and never reached the new case. Mapping added, and the test that pinned `unknown` updated.
* **The cheatsheet leaked RAG unconditionally** — `rag (knowledge base)` in the step-type list and the `rag` docs-map entry sat in `BODY_CHEATSHEET`, which every prompt carries. A prompt without the RAG endpoints therefore still said knowledge bases exist. Both moved into the conditional section, which now has a *no-reads* variant that names the `rag` step and states the boundary rather than going silent — silence is what produced the invented call.
* **The ingestion claim did not track its own endpoint** — `grantsKnowledgeBaseReads` gates a section that promised an ingestion check while requiring only the two config reads. Split into `grantsIngestionStatusReads` rather than requiring all three: the config reads are a complete capability alone, so demanding the third would drop the whole section on a deployment missing one endpoint.
* **`grantsKnowledgeBaseAuthoring` missed the duplicate verb** (a *suppressed* Copilot comment, which carries no thread — found by grepping the review body). `POST /ragstore/rags/{id}` is `duplicateRag`, and a copy of a knowledge base is a new knowledge base, so granting it would have left the prompt telling an operator that CAN create one that it cannot. Added, with a test covering all four authoring routes.
* **Plaintext credentials in a `RagConfiguration`** — the exposure is real but not new: `GET /llmstore/llms/{id}` returns a plaintext key verbatim too, and `RestLlmStore` says so in its own javadoc. RAG was, however, the one credential-carrying store with **no write-time warning**, so it now has the same one `RestLlmStore` and `RestChannelIntegrationStore` already had (warn, never reject — a rejection breaks vault-less instances). Redacting config reads platform-wide is a separate change; doing it for RAG alone would imply the other stores are safe.

**Files:** `ui/manager/src/lib/operator/tool-scopes.ts`, `.../system-prompt.ts`, their tests, `src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java`, `src/main/java/ai/labs/eddi/configs/rag/rest/RestRagStore.java`, `src/test/java/ai/labs/eddi/engine/mcp/{McpAdminToolsSwitchCoverageTest,McpAdminToolsTest}.java`, `src/test/java/ai/labs/eddi/configs/rag/rest/RestRagStoreWriteValidationTest.java`, `docs/mcp-server.md`

***

## 🔒 fix(security): close the CWE-117 gap in the half of a log line no call site can reach (2026-09-20)

**Repo:** EDDI (`fix/log-injection-record-boundary-handler`)

`LogSanitizer.sanitize(...)` at a call site only ever covered the log **message**. `quarkus.log.console.format` ends in `%s%e`, and `%e` renders a stack trace whose FIRST line is the throwable's own `toString()` — `ClassName: message`. So an attacker-controlled CR/LF inside an **exception message** reached the console verbatim and forged a record that reads as a genuine, server-authored line, no matter how carefully the message half was sanitized. 412 log calls in `src/main/java` pass a throwable (244 as a trailing argument, 168 as JBoss `*f(e, …)`), and none of them could fix this themselves.

Dropping the throwable at those call sites was never the trade: `RestAgentAdministration`'s deploy-failed WARN tells the client only *"Deployment failed. Check server logs for details."*, so the stack trace is the sole diagnostic a failed deployment leaves.

### What changed

* **`LogSanitizer.escapeRecordBoundaries(String)`** — a second, record-level rule beside the existing call-site `sanitize(...)`. It escapes rather than destroys: CR → `\r`, LF → `\n`, U+2028/U+2029 and every other ISO control character → `\uXXXX`, TAB kept verbatim. Returns the same instance when nothing needs escaping, and `null` for `null` (unlike `sanitize`, which renders `null` as the string `"null"` — doing that to a throwable's message would turn a printed `java.io.IOException` into `java.io.IOException: null`).
* **`LogRecordRedactor`** now applies both rules in one pass: `SecretRedactionFilter.redact` then `escapeRecordBoundaries`, to the record's formatted message and to every message in its throwable graph (causes and suppressed included). `RedactedThrowable.of` takes the message rewrite as a `UnaryOperator<String>` so one walk of the graph applies both rules instead of nesting one stand-in inside another.
* **`BoundedLogStore.capture`**'s own fallback path (used when the upstream pass threw) applies the same `LogRecordRedactor.rewrite`, so the ring buffer, the DB and the SSE live tail agree with the console.
* **Two log calls that this change would otherwise have made uglier**: the `\n` in `ConversationStepRunner`'s "Conversation not ready" ERROR became `": "` (the throwable is passed too, so `%e` prints the trace anyway), and `ApiCallExecutor`'s trailing `\n` on the execution-time INFO is gone (the pattern already ends in `%n`). They were the only two deliberately multi-line log messages in `src/main/java`.

### Design decision — escape the throwable's MESSAGE, not the rendered trace

The obvious reading of "sanitize the rendered `%s%e`" is to scan the finished stack trace and escape the line breaks that do not begin a genuine continuation line (`\tat` , `Caused by:`, `\t... N more`). **Rejected**: those three prefixes are also three strings an attacker can put in an exception message, so such a scan has to decide which `Caused by:` is the JVM's and which is the payload, and it has no way to know.

There is no need to guess. In a rendered trace the only text an attacker reaches is the `toString()` of each throwable in the graph; every other line is generated by the JDK from the `StackTraceElement` array. So EDDI escapes the messages *before* the trace is rendered, by substituting a copy of the throwable, and lets the JDK produce the structure from clean input. Nothing is parsed, nothing is guessed, and `LogRecordBoundaryForgeryTest` asserts the frames, the `Caused by:` and the `... N more` elision come out identical to what the original threw.

Two further choices worth stating: **TAB is kept** (it cannot end a record, and it is what indents `\tat …`), and **a backslash is not doubled** — the escaping is therefore not injective, which is a cosmetic ambiguity rather than a forgery, and the alternative doubles every backslash in the Windows paths and regexes exception messages are full of.

It is also a rewrite of the record rather than a new console formatter, matching the reasoning already recorded in `LogRecordRedactor`: one definition of "what goes out" for every destination. The filter is wired to the console handler alone via `quarkus.log.console.filter=eddi-log-capture`; a file or syslog handler would need the same filter, and the test below fails if that property or the `%s%e%n` format moves out from under the claim.

### Tests

New `LogRecordBoundaryForgeryTest` (10 tests) asserts on **rendered** output — a real `PatternFormatter` built from the pattern read out of `src/main/resources/application.properties` — because `LogCaptureSupport.captureLogsOf` reads `getMessage()`/`getParameters()` but not `getThrown()` and so cannot see this defect at all. Its shared invariant: after the first, every line of a rendered record must be a continuation the JDK generated. Covers the exception message, a cause, a suppressed exception, U+2028, the message half, a format parameter, plus "a clean record renders byte-for-byte as before and keeps its throwable" and the config guard. `LogSanitizerTest` gains 8 cases for the new method.

**Mutation-checked.** Removing the escaping entirely fails 7 of 10 (the 3 survivors are the must-not-change tests). Escaping the message but not the throwable fails exactly the 5 throwable-half tests — so none of them pass on the strength of the message fix.

### And the message-level alerts, folded in

The handler above stops any of these forging a record at *runtime*, but CodeQL's `java/log-injection` is a dataflow rule and keeps flagging the call site regardless — and if the filter is ever detached from a handler, the call site is what is left. So the same branch also applies the ordinary one-line `LogSanitizer.sanitize(...)` to **38 sinks across the eight files** the alerts name:

| File                           | Sinks | The tainted arguments                                                                      |
| ------------------------------ | ----- | ------------------------------------------------------------------------------------------ |
| `GroupHitlCoordinator`         | 16    | `gc.getId()`, `gc.getGroupId()`, `groupConversationId`, `entry.getKey()`, `e.getMessage()` |
| `GroupConversationService`     | 11    | `gc.getId()`, `gc.getGroupId()`, `phase.name()`, `outcome.reason()`                        |
| `MemberTurnExecutor`           | 3     | `member.agentId()`, `gc.getId()`, `gc.getGroupId()`, `subGroupId`                          |
| `ConversationHitlService`      | 3     | `conversationId`                                                                           |
| `PhaseExecutionEngine`         | 2     | `gc.getId()`, `phase.name()`, `decision.outcome()`                                         |
| `AuditLedgerService`           | 1     | `entry.agentId()`, `e.getMessage()`                                                        |
| `AgentGroupStore`              | 1     | `groupConfiguration.getName()`, the phase name                                             |
| `SlackGroupDiscussionListener` | 1     | `groupConversationId`, `e.getMessage()`                                                    |

Only String-typed arguments are wrapped; the enums, `Instant`s and counters in the same calls are left alone. `MemberTurnExecutor` and `SlackGroupDiscussionListener` gained the import; each of the other six already had it, and each call follows the style its own file already used (qualified `LogSanitizer.sanitize` in six, the static import in `ConversationHitlService` and `AuditLedgerService`).

The alert list was resolved through `gh api`, not read off `main` at HEAD: a CodeQL alert's line number is relative to `most_recent_instance.commit_sha`. Two of the 41 reported alerts turned out to be stale against an older sha — one line had already been sanitized, the other no longer exists — which is how 41 became 38. The eight files carry a further \~70 log arguments of the same shape that CodeQL has *not* flagged, overwhelmingly `e.getMessage()`; those are left alone, because sanitizing them is a codebase-wide policy question and not this PR's.

**Tests.** `SanitizedLogSinksTest` pins all 38 at the source: each is keyed by a fragment of its own message rather than a line number, and every flagged argument must occur only inside a `sanitize(...)`. Dropping one fails the build with the file, the message and the expression named. `GroupHitlCoordinatorLogInjectionTest` covers the two sinks reachable through a public method with one mock — the forged-id and the forged-exception-message halves — in the `LogCaptureSupport` idiom the earlier regression tests established. Both mutation-checked.

A source guard rather than 38 behavioural tests is a deliberate call and is argued in the test's own Javadoc: the rest sit inside a phase loop or a state-race `catch` that takes a whole group discussion to reach, and a test that builds one to observe a single WARN grades the harness more than the fix.

### What's next

* `RestAgentAdministration`'s deploy-failed WARN carries a comment on branch `fix/log-injection-agent-deployment-logs` (#799) explaining that the throwable cannot be sanitized and that only a log handler can fix it. That branch is not merged, so the comment does not exist on `main` and could not be updated here: **whichever of the two lands second must update it** to say the handler now exists.
* 110 further `java/log-injection` alerts remain open on `main` in files this PR does not touch — `RestScheduleStore`, `RestUserMemoryStore`, `VaultSecretProvider`, the REST stores and others. `RestAgentAdministration` and `AgentFactory` among them are PR #799's scope and were left to it. None of them can forge a record at runtime now, so they are alert hygiene rather than exposure.

***

***

***

## 🕷️ feat(ingestion): web crawler — streaming, bounded, robots-aware (2026-09-17)

**Repo:** EDDI (`feat/ingestion-web-crawler`)

### Why a rewrite rather than a patch

The crawler salvaged from PR #529 was competently written but wrong in shape: it buffered every page's full HTML in a `List` and returned it when the crawl finished, identified pages by the URL *requested* rather than the one reached, read every body with an unbounded `ofString()` **before** checking its Content-Type, and had no run budget. None of that is patchable without touching every line.

It also had no `robots.txt` at all. EDDI installations crawl sites their operators do not own, on a schedule — ignoring robots gets the installation blocked and its operator a complaint.

### What replaces it

* **`WebCrawler`** — BFS, streaming to a `CrawlSink` one page at a time, so memory is independent of the site's size. Budgets for pages, fetch attempts, bytes per page, total bytes and wall clock, each reported as a `StopReason`. Cancellation checked between pages.
* **`CrawlUrls`** — canonicalization. Lowercases scheme and host **but not the path**: the draft lowercased the whole URL, so `/Docs/Guide` and `/docs/guide` collapsed into one entry and whichever came second was silently never crawled. Also strips fragments, default ports, tracking parameters and index filenames, and sorts query parameters, so one page is not ingested three times.
* **`UrlPattern`** — exclude globs matched against the **path**, with every metacharacter escaped and compiled once. Two defects fixed: the documented `*.pdf` could never match anything (`*` cannot cross the slashes in `https://host/`), and a pattern containing `+` or `(` threw `PatternSyntaxException` inside the crawl loop, where a blanket catch logged it as a *fetch* error and dropped the current page's links — one bad pattern reduced a crawl to its seed URL.
* **`RobotsPolicy`** — groups, longest-match `Allow`/`Disallow`, `*`/`$`, `Crawl-delay` and `Sitemap`. Blank lines deliberately do not end a group: real files are full of them, and orphaning a group's rules silently allows everything the site meant to block.
* **`PageFetcher`/`SafeHttpPageFetcher`** — `sendValidated` per request (the crawler follows links harvested from third-party pages, which is as user-controlled as a URL gets), with a hard cap on the body read and charset taken from the header or sniffed from the document. Assuming UTF-8 turns legacy pages into mojibake, and mojibake embeds without complaint.

Identity is the URL after redirects, re-checked against the scope: a 301 to another host satisfied `sameSiteOnly` on the pre-redirect host and smuggled a foreign page into the knowledge base. `<link rel="canonical">` is honoured, but only when it stays on the same host.

Sitemaps from robots.txt are crawled without needing a link — the cheapest discovery there is, and the mitigation for the one cost of conditional requests: a 304 has no body, so an unchanged page's links are not re-read that run.

### Tests

**113 unit tests, no network, no container, no test server.** The `PageFetcher` seam is there for exactly this: `FakeSite` serves an in-memory website, so scope decisions, budgets, redirect identity, robots, conditional requests, charset handling and error accounting all run in the unit gate. The draft's only coverage was one Testcontainers test the unit run does not execute, which is why none of these defects were caught.

Three of the five failures on the first run were real bugs the tests found, not test bugs: sitemap URLs bypassed the scope check, `https://host/` and `https://host` canonicalized differently, and fetching the canonicalized form invented URLs the site never published (the crawler now fetches the address as published and uses the canonical form only as identity).

Mutation-checked: reverting the final-URL identity and re-lowercasing the path fails four tests.

### Next

The source configuration and the pipeline that ties crawl → convert → state store → embed, with vector removal driven by the tombstone list, plus the Manager UI.

## 📄 refactor(ingestion): HTML→Markdown converter, and WebScraperTool stops duplicating it (2026-09-17)

**Repo:** EDDI (`feat/html-to-markdown-converter`)

### Why

Ingesting a web page for retrieval needs more than `Jsoup.text()`. Flat text loses the structure a chunker needs (heading boundaries, which section a passage came from) and merges neighbouring blocks into single tokens. `WebScraperTool` was doing exactly that, with its own inline `"script, style, nav, footer, header, aside"` strip — a second, weaker copy of the same rules.

This lands the converter salvaged from the stale PR #529, with its defects fixed, and makes the existing tool use it instead of its own extraction.

### Why not a library

Checked the classpath first: jsoup and pdfbox are present; flexmark-html2md, commonmark and the langchain4j document parsers are not. A general HTML→Markdown library optimises for fidelity to the source document, while ingestion wants the opposite — aggressive removal of everything a reader skips. \~450 lines with a 60-case suite is cheaper than a new supply-chain dependency for that job.

### Defects fixed from the salvaged draft

Each of these silently degraded what reached the vector store; all 46 of the draft's own tests passed with them present, which is the point — bad ingestion has no stack trace.

* **Adjacent blocks merged.** `div`/`section`/`article` appended children with no separator, so `<div>Hello</div><div>World</div>` embedded as `HelloWorld`.
* **`<header>` stripped globally**, deleting the page title in the `<article><header><h1>` layout most documentation themes use. Now only `body > header` (the site banner) is removed.
* **Unescaped `|` in table cells**, which ends the column early and shifts every later value under the wrong header — corruption that surfaces only as a wrongly cited number.
* **Code blocks flattened**: `text()` collapses whitespace, so every multi-line sample became one line. Uses `wholeText()`.
* **Headings resolved links against `null`**, leaving them relative and useless as citations.
* **`<dl>`, `<details>`, `<figure>` fell through to the default branch** and ran together — collapsed `<details>` content is still content and is now ingested with its summary as the label.
* Alt-less images emitted `![](url)`: tokens spent on nothing. Dropped.
* Boilerplate selectors extended with `role=navigation|banner|contentinfo|complementary`, cookie banners, buttons, `svg`, `template`, `aria-hidden`.
* `maxLength <= 0` truncated everything; now falls back to the default.
* Dead `inPreBlock` plumbing removed (never set true by any caller).

### WebScraperTool

`extractWebPageText` now returns Markdown from the converter rather than a `text()` dump prefixed with `Title:` . Same 5000-character cap. This is a visible change to an LLM tool's output, and a deliberate one: the model gets headings, lists and tables instead of one run-on paragraph. Its `extractMainContent` helper is gone — it was the duplicate.

### Tests

60 converter tests: all 46 inherited from the draft pass unchanged against the rewrite (useful evidence the behaviour was preserved where it was right), plus 14 in `HtmlToMarkdownConverterSalvageTest`, one per defect above. `WebScraperToolExtendedTest` gains a case asserting structure survives.

Note: `WebScraperToolTest` cannot run in this environment — its `setUp` constructs a real `SafeHttpClient`, and creating an `HttpClient` here fails with "Unable to establish loopback connection". Pre-existing and environmental; CI covers it.

## ♿ fix(ui): closing a dialog hands focus back to what opened it (2026-09-19)

**Repo:** EDDI (`fix/dialog-return-focus`)

`AccessibleDialog` promises "return focus to trigger element on close". It did not keep that promise in either of the two ways the Manager closes a dialog, so keyboard and screen-reader users were left on `<body>` and had to find their place from the top of the page again.

### What was wrong

* **With an `autoFocus` field inside** (`CreateAgentDialog`'s Name, the dictionary picker's search), "what had focus" was recorded in a `useEffect`. React applies `autoFocus` during commit, before any effect runs, so the recorded element was the dialog's own field. On close it had unmounted, and focusing it did nothing.
* **When closed by unmounting.** `ShareDialog` (on the Agents, Workflows and resource list pages) and the Triggers dialog are rendered as `{target && <X open … />}` and close by unmounting. Focus was only restored on an `open === false` render, which an unmount never produces.

### What changed

* `ui/manager/src/components/ui/accessible-dialog.tsx`: the trigger is recorded while rendering the opening render, before React commits the dialog. It is restored in the effect's cleanup, which runs on close and on unmount alike, but only if focus was actually lost with the dialog (it sits on `<body>`). That guard does two things: StrictMode runs the cleanup once on mount with the dialog still up, where an unconditional restore pulled focus out of the open dialog, and focus the user deliberately moved elsewhere is not taken back.
* `ui/manager/src/components/ui/__tests__/accessible-dialog-focus-return.test.tsx` (new): autoFocus close, unmount close, StrictMode mount, and focus moved elsewhere. Against `main` the first two fail; with the `<body>` guard removed the last two fail.

### Note

This rewrites the same effect as #788 (initial focus no longer steals from a focused field), which landed first. `main` is merged in here and the conflict resolved to keep both: #788's guarded, cancelled frame, and this branch's cleanup restore — the cleanup now cancels the frame *and* returns focus.

***

## 🧪 fix(ui): a dialog no longer takes focus from a field the user is typing in (2026-09-18)

**Repo:** EDDI (`fix/share-dialog-flaky-test`)

`UI Manager Checks` failed intermittently (run 35295321603) in two unrelated-looking tests that pass locally: `share-dialog` › "does not let two quick Enters skip the ownership confirmation" (`share-owner-warning` never appeared) and `create-agent-dialog` › "allows typing in description field" (the field was empty after `user.type`). They had one cause, and it was in the component, not the tests.

### Root cause

`AccessibleDialog` moved initial focus to its first focusable element (the header's Close button) inside a `requestAnimationFrame` scheduled on open. On a loaded runner that frame fired *after* the test had clicked into a field, and user-event sends keystrokes to `document.activeElement`: "bob" went to the Close button, the share subject stayed empty, Enter failed validation, and no warning was ever rendered. The same frame overrode every `autoFocus` inside the dialog in the real UI — `CreateAgentDialog` autofocuses its Name field, and focus ended on the X a frame later.

Reproduced by stubbing `requestAnimationFrame` to a 30–150 ms timeout: the original share-dialog tests then fail with exactly the CI error, and the create-agent tests with exactly the empty value.

### What changed

* `ui/manager/src/components/ui/accessible-dialog.tsx`: the frame leaves focus alone when it is already inside the dialog, and is cancelled on cleanup. The trap, Escape and return-focus behaviour are unchanged.
* `ui/manager/src/components/ui/__tests__/accessible-dialog.test.tsx` (new): holds the frame and releases it by hand, so "focus reached a field first" is deterministic. Covers the empty-dialog default (Close gets focus), a field focused before the frame, and an `autoFocus` field. Mutation-checked: removing the guard fails the latter two.
* `ui/manager/src/components/workspaces/__tests__/share-dialog.test.tsx`: the warning assertions made straight after a user event now wait (`findByTestId` for it appearing, `waitFor` for it disappearing, the latter safe because each test has just seen it present). This is hygiene, **not** the fix — with the delayed frame and the old component these still fail, just after the wait. The behavioural guards (`shared` not called, `sentSubject` null) are unchanged.

### Verification

With the fix, the 80 ms and 150 ms delayed-frame copies of both the old and the new share-dialog tests and of `create-agent-dialog` pass (246/246); without it, they fail as CI did. Full Manager suite with coverage green locally.

### Not done

`previousFocusRef` is captured in the same effect, after an `autoFocus` child has already taken focus, so on close focus "returns" to that (now unmounted) field instead of the trigger. Pre-existing and separate; left alone here.

***

## 🔐 fix(a2a): make the A2A endpoints' anonymity real, and decide which of them deserve it (2026-09-17)

**Repo:** EDDI (`fix/a2a-anonymous-discovery-permissions`)

`RestA2AEndpoint` annotated five endpoints `@PermitAll`, intending them to be reachable by peer agents that hold no EDDI credential. None of them were named in a `quarkus.http.auth.permission.*` entry. **Quarkus evaluates those path policies before declarative RBAC**, so the `/*` catch-all (`policy=authenticated`) claimed all five: on any instance with `quarkus.oidc.tenant-enabled=true`, Agent Card discovery answered **401** to exactly the callers it exists for — a bare 401, since `quarkus.oidc.application-type=service` sends no login redirect. The annotation and the deployment had disagreed for as long as the endpoints existed.

Nothing caught it because `A2aEndpointIT` runs against a `BaseStandaloneIT` instance with authorization off, where `DisabledAuthController` switches the path policies off wholesale and a permitted path and a protected one answer identically.

### The decision, endpoint by endpoint

Not all five were meant to be anonymous, so this is not "add a permit entry for the five".

| Endpoint                               | Posture                                  | Why                                                                                                                                                                                                                                                                                                                                                       |
| -------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /.well-known/agent.json`          | **permit**                               | The A2A discovery convention. A peer reads the card *before* it holds any credential                                                                                                                                                                                                                                                                      |
| `GET /a2a/agents/{agentId}/agent.json` | **permit**                               | The card EDDI's own client fetches — `A2AToolProviderManager.fetchAgentCard` sends `apiKey` only if one is configured. Needs the agent id, so it discloses one agent, not the roster                                                                                                                                                                      |
| `GET /a2a/agents`                      | **authenticated** — `@PermitAll` removed | The whole roster: every A2A agent's name, description, skills and URL. Strictly more than the skill-name list that sits behind `eddi.a2a.capabilities.public`, and nothing in the protocol or in this repo fetches it                                                                                                                                     |
| `GET /.well-known/capabilities`        | **permit** at the HTTP layer             | `eddi.a2a.capabilities.public` (default `false`) is the only *authorization* gate — `eddi.a2a.enabled` gates it as well, but neither looks at the caller. While either is off the handler answers 404 to authenticated and anonymous callers alike, so permitting the path widens nothing — and while both are on, "public" has to mean *without a token* |
| `GET /.well-known/capabilities/skills` | **permit** at the HTTP layer             | Same flag, same reasoning                                                                                                                                                                                                                                                                                                                                 |

Where code and config disagreed, the **code** was changed: `listA2AAgents` lost `@PermitAll` and gained `@Authenticated`, rather than gaining a permit entry.

### Design decisions

* **`/a2a/agents/*/agent.json`, not `/a2a/agents/*`.** Quarkus 3.39's `ImmutablePathMatcher` supports an inner wildcard matching exactly one path segment, and the distinction is load-bearing twice over. A `/a2a/agents/*` prefix would (a) permit the roster, because the prefix registers under `/a2a/agents` and wins over the catch-all, and (b) **break A2A outright**: the JSON-RPC `POST /a2a/agents/{agentId}` would match a `methods=GET` entry, and Quarkus *denies* on a method mismatch rather than falling through. Both are asserted.
* **No `/.well-known/*` wildcard.** RFC 9728 protected-resource metadata is planned under that prefix (`planning/saas-connectors-plan.md` §6.3); a wildcard would pre-permit it, and anything else later dropped there, with nobody deciding to. The paths are enumerated and a test asserts a sibling still requires authentication.
* **One knob for capability discovery.** The permission entry does not re-express `eddi.a2a.capabilities.public`; duplicating the gate into a second property is how the two drift.

### Tests

* **`A2aEndpointPermissionsTest`** (new, unit — runs in `./mvnw test`, no container). Feeds the shipped `application.properties` through Quarkus's own `ImmutablePathMatcher` and resolves the effective policy per path and method, replicating `findHttpMatchers`' method-filtering rule. Its last test reflects over `RestA2AEndpoint` and asserts every `@PermitAll` / `@Authenticated` method resolves to the policy it claims — so the *next* endpoint added with a forgotten permit entry fails here. Mutation-checked three ways: removing the card entry reproduces the original `[authenticated]`; widening to `/a2a/agents/*` catches both failure modes above; a `/.well-known/*` wildcard trips the sibling assertion.
* **`ui/manager/e2e/auth/a2a-discovery.spec.ts`** (new). The auth E2E tier is the only one that enforces authentication, so it is where the real status codes belong: it creates an A2A-enabled agent (a card is built from stored config, no deployment needed), then asserts 200 anonymous for both cards and both capability endpoints and 401 anonymous for the roster, the JSON-RPC surface and an unlisted `/.well-known` sibling. It opens with its own "this backend really is enforcing auth" guard so it cannot pass vacuously, and re-checks the roster with an admin token so the 401 is provably about anonymity.
* `docker-compose.integration-keycloak.yml` sets `EDDI_A2A_CAPABILITIES_PUBLIC=true`, because with the flag off the spec could not tell "permitted, flag says no" (404) from "the permission entry is missing again". The flag-off 404 stays covered by `RestA2AEndpointTest`.

### Files

* `src/main/resources/application.properties` — new `a2a-agent-card` and `a2a-capabilities` permit entries, GET-only, before the catch-all
* `src/main/java/ai/labs/eddi/engine/a2a/RestA2AEndpoint.java` — `listA2AAgents` is `@Authenticated`; Javadoc on every endpoint records the posture and why
* `src/test/java/ai/labs/eddi/engine/a2a/A2aEndpointPermissionsTest.java` — new
* `ui/manager/e2e/auth/a2a-discovery.spec.ts` — new
* `ui/manager/docker-compose.integration-keycloak.yml` — capability flag on
* `docs/a2a-protocol.md` — an "Anonymous?" column and a "Who can call them" section
* `docs/configuration-reference.md` — `eddi.a2a.capabilities.public` says what it actually gates

### Review follow-up (PR #782)

Three findings, all valid, all fixed on the branch:

* The generic guard resolved the HTTP verb as `isAnnotationPresent(GET) ? "GET" : "POST"`, so a future `@PermitAll @PUT` would have been graded against a method it does not serve — and since the permit entries are GET-only, that is precisely the drift the guard exists to catch. The verb now comes from whichever annotation is meta-annotated `jakarta.ws.rs.HttpMethod`, and the guard fails on anything other than exactly one. Confirmed by planting a `@PermitAll @PUT` endpoint plus a permit entry naming POST: the old code passed it, the new code names the entry and the verb.
* `docs/a2a-protocol.md` said everything is reachable without a token when OIDC is off. True of authentication, misleading about the result — `eddi.a2a.capabilities.public` is an independent switch and its endpoints 404 either way while it is off.
* `docs/configuration-reference.md` said the capability endpoints expose agent *names*. `CapabilityMatch` is `(agentId, skill, confidence, attributes)` — ids. The surface is smaller than the doc claimed, which if anything strengthens the case for leaving `/a2a/agents` (names, descriptions, URLs) authenticated.

**Second pass** (CodeRabbit's first review was rate-limited before it saw the fix commits, so both bots were asked for a fresh look):

* `eddi.a2a.capabilities.public` was described as "the only gate". `eddi.a2a.enabled` gates the capability endpoints too (`if (!a2aEnabled || !capabilitiesPublic) → 404`). Reworded in all five places that said it to "the only *authorization* gate — neither flag inspects the caller", which is the claim the permit entry actually rests on.
* The Agent Card's `authentication.credentials` is built from `quarkus.oidc.auth-server-url`, i.e. the URL **EDDI** uses to reach the IdP. The shapes that bundle Keycloak set that to an in-cluster or compose hostname, so the token endpoint advertised to an outside peer does not resolve — which this PR makes consequential, because the card is now anonymously readable under auth. Initially deferred as a config-design decision; **fixed here** once CodeRabbit raised it independently at Major severity — see the fourth pass below.

**Third pass — two findings Copilot&#x20;*****suppressed*****&#x20;into its review body**, where they have no thread and a `reviewThreads` query cannot see them. Both were real, and both are properly this PR's:

* **`/.well-known/agent.json` fanned out over the whole roster.** `getDefaultAgentCard()` called `listA2AAgents()` and returned `cards.get(0)` — building a card for every A2A-enabled agent (`getCurrentResourceId` + `read` + `readDescriptor` apiece, up to 100 candidates) and discarding all but one. Merely wasteful while the endpoint required a token; an amplification vector now that this PR makes it anonymous. `AgentCardService.getDefaultAgentCard()` now stops at the first match (`collectA2AAgents(stopAtFirst)`), and `AgentCardServiceTest` asserts **one** store read across 25 candidates rather than asserting the card — the card was always right, the cost was not.
* **The E2E cleanup scored a failed request as success.** `await call().catch(() => undefined)` followed by `res === undefined || res.status() < 400` passed when the request never completed, leaking the A2A-enabled fixture agent. That one contaminates specifically: the default Agent Card is whichever A2A agent comes first, so a leftover is exactly what a later run reads. The soft assertion now requires a real 2xx/3xx and reports the status or the error.

**Fourth pass — the advertised token endpoint, raised independently by both reviewers.** Deferred twice on scope, then implemented: two reviewers agreeing, both framing it as "the permission change makes this pre-existing URL consequential", outweighed the argument for keeping it separate.

`AgentCardService.advertisedTokenEndpoint()` resolves what the card advertises:

* **`eddi.a2a.public-token-endpoint`** (new, optional) — advertised verbatim. The *endpoint*, not the issuer, because the path is the provider-specific part.
* Otherwise `<issuer>/protocol/openid-connect/token`, where `<issuer>` is **`eddi.keycloak.public.url`** grafted onto the realm path from `quarkus.oidc.auth-server-url`, falling back to `quarkus.oidc.auth-server-url` itself. Both shipped authenticated deployments already set the public URL — Helm *requires* it, since the Manager SPA cannot start a login without it — so they become correct with no new configuration. Only the origin is taken from it; the realm path stays what EDDI is configured against, so the two cannot drift. **Nothing moves for a deployment that does not opt in**, which is what made this safe to do inside a permissions PR.

The derivation **assumes Keycloak**, which the docs now say rather than gloss. OIDC discovery would remove the assumption instead of documenting it and is the right follow-up; it is not done here because it turns rendering an anonymous card into an outbound HTTP call, needing `SafeHttpClient`, a cache and a failure policy.

Verified end to end rather than by unit test alone — built the image, ran the Keycloak tier, and read the anonymous card: `credentials` is now `http://localhost:8180/realms/eddi/protocol/openid-connect/token`, the published port an outside peer sees, where it was `http://keycloak:8080/...`. That URL is provably reachable — it is the one the test fixtures fetch their tokens from. `a2a-discovery.spec.ts` now asserts it exactly, as the reviewer asked.

**Fifth pass — a bug in the fourth pass.** CodeRabbit (Major) caught that the property introduced above was the *issuer*, while the Keycloak path `/protocol/openid-connect/token` was appended to whatever it named. So the one knob documented as "the escape hatch for a non-Keycloak IdP" handed an Okta or Auth0 operator their issuer with a Keycloak path stapled on — it did not do the job it was documented as doing, and the docs, the commit message and the reply to the reviewer all repeated the claim.

Replaced `eddi.a2a.public-auth-server-url` with `eddi.a2a.public-token-endpoint`, advertised verbatim: **one** property instead of two, and it actually covers the case the other one claimed to. The property was one commit old and unreleased, so nothing depended on it. Two tests pin the distinction, including one asserting the Keycloak path is never appended to an endpoint given in full.

Also seen this pass and **not** fixed here: `UI Manager Checks` went red on `share-dialog.test.tsx › does not let two quick Enters skip the ownership confirmation`, a file this branch does not touch. It passes 15/15 locally three runs in a row, and the cause is visible in the test — a synchronous `expect(screen.getByTestId("share-owner-warning"))` immediately after an async `userEvent.type`, with no `waitFor`, so a slow runner loses the race. A real flake with a one-line fix, but in unrelated code; filed separately rather than smuggled into a permissions PR.

### What's next

Nothing outstanding for A2A. The generic lesson — `@PermitAll` is not a permit entry — applies to any future endpoint meant to be anonymous; `A2aEndpointPermissionsTest` only guards `RestA2AEndpoint`, and widening it to every `@PermitAll` in the codebase would be a reasonable follow-up.

***

## 🔏 chore(ci): settle the dependency-review licence policy — deny-list kept, broadened, documented (2026-09-17)

**Repo:** EDDI (`chore/dependency-review-license-policy`)

### Why

`.github/workflows/dependency-review.yml` printed a deprecation warning on every PR ("The deny-licenses option is deprecated for possible removal in the next major release"). The comment above the option already recorded the deferral: migrating to `allow-licenses` means enumerating every licence the project accepts, which is a repo-wide policy decision, not a mechanical swap. This session established the real input, put the decision to the maintainer, and implemented the answer.

### What the dependency graph actually contains

Enumerated three ways: `license-maven-plugin:add-third-party` for the resolved Maven tree (582 artefacts), `npm query ":not(.dev)"` for both UIs, and — the one that matters — the live graph the action actually reads, `gh api repos/labsai/EDDI/dependency-graph/sbom`.

GitHub's Maven graph parses `pom.xml` directly and does **not** resolve transitives, so the policy is evaluated against 95 Maven entries, not 582:

| Count | Licence                                                                                                                                |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------- |
| 58    | `NOASSERTION` — BOM-managed (`io.quarkus:*`, `jakarta.annotation`, `caffeine`) or `${property}`-versioned (all 22 `dev.langchain4j:*`) |
| 27    | `Apache-2.0`                                                                                                                           |
| 4     | `MIT` (testcontainers)                                                                                                                 |
| 2     | `Apache-2.0 AND BSD-3-Clause AND MIT` (maven plugins)                                                                                  |
| 2     | `LicenseRef-bad-non-standard` — `org.jsoup:jsoup`, `io.github.classgraph:classgraph`; both are really MIT                              |
| 1     | `BSD-2-Clause` (postgresql)                                                                                                            |
| 1     | `EPL-2.0 OR (Apache-2.0 AND EPL-2.0)` (jacoco)                                                                                         |

npm contributes 1137 graph entries but `fail-on-scopes` defaults to `runtime` and `main.ts` runs the licence check on the scope-filtered set, so only production deps count: manager 179 (MIT 164, OFL-1.1 8, ISC 2, Apache-2.0 2, BSD-3-Clause 1, `MPL-2.0 OR Apache-2.0` 1) and chat 126 (MIT 122, ISC 2, BSD-3-Clause 1). The MPL-2.0, CC-BY-4.0 and Python-2.0 entries in the graph are all devDependencies.

### Decision

Keep `deny-licenses`, broaden it, and record why the warning is accepted. Three findings from reading the action's source made the allow-list migration the worse option rather than merely the more expensive one:

1. **It would fail the build today.** `spdx.satisfies()` returns `false` for an expression it cannot match, so the two `LicenseRef-bad-non-standard` entries land in `forbidden` → `setFailed` under an allow-list. Under a deny-list `satisfiesAny()` returns `false` and they pass. Migrating would mean two permanent per-package exclusions that exist only to work around GitHub's own normalisation.
2. **It buys no coverage.** The 58 unknown-licence entries go to the `unlicensed` bucket, and `printNullLicenses()` only prints — it never sets `issueFound`. They are informational in *both* modes.
3. **Removal is not scheduled.** Upstream issue #997 was closed by stalebot after 180 days of inactivity, not by a decision, and v5.0.0 (2026-05-08) is a node20 → node24 runtime bump that leaves `deny-licenses` fully documented in `action.yml`. There is no newer v4 digest, so the pin stays at v4.9.0.

The line is drawn at the library level, because EDDI is Apache-2.0 and ships a fat jar inside a distributed Docker image — a combined work. Permissive and weak (file-level) copyleft stay acceptable; EPL especially has to, since the whole Jakarta EE / JUnit / JaCoCo layer Quarkus pulls in is EPL, usually dual with GPL-2.0 under the Classpath Exception. Denied: AGPL-3.0, GPL-2.0, GPL-3.0, LGPL-2.0/2.1/3.0 (each `-only` and `-or-later`), SSPL-1.0, BUSL-1.1, Elastic-2.0. The additions past the original two are not hypothetical — the realistic hazard for middleware is a dependency relicensing to source-available, and EDDI already depends on MongoDB and Elasticsearch clients.

### Verified, not assumed

Ran the candidate list through the same libraries the action uses (`@onebeyond/spdx-license-satisfies`, `spdx-expression-parse`) against every licence value in the live SBOM:

* nothing currently in the graph is newly denied — the change is a strict superset of the old behaviour with no regression;
* every listed hazard is caught;
* deprecated ids still match: a dep declared `GPL-3.0` is caught by `GPL-3.0-only`, so modernising the identifiers does not weaken the gate;
* Classpath-Exception artefacts do **not** false-positive — `EPL-2.0 OR GPL-2.0-with-classpath-exception` and `CDDL-1.1 OR GPL-2.0-only WITH Classpath-exception-2.0` both pass with `GPL-2.0-only` and `GPL-2.0-or-later` denied. This was the main risk of adding GPL-2.0 and it is disproven, not hoped.

Known trade-off, recorded in the workflow: `satisfiesAny()` treats `A OR B` as denied when either side is, so a *directly declared* dep offering `Apache-2.0 OR LGPL-2.1` would be flagged despite the Apache option. Nothing hits this today — the dual-licensed artefacts (`net.java.dev.jna`, `org.javassist`, `com.github.java-json-tools:*`) are all transitive and invisible to GitHub's Maven graph.

### Dropped the Caffeine exemption

The `allow-dependencies-licenses` entry for Caffeine is **removed**. It was first kept with a corrected comment calling it cosmetic; CodeRabbit pushed back on the PR, and it was right. `groupChanges` in the action's `src/licenses.ts` says so in its own comment — *"we leave it off of the `licensed` and `unlicensed` lists"* — so the input drops a package from the licence check **entirely**, not just from the unknown-licence notice. The exemption therefore also waived `deny-licenses` for any future Caffeine release whose licence GitHub *can* resolve, while buying nothing: per finding 2 an unresolved licence cannot fail the build anyway, and 57 other entries sit in the same bucket unexempted. Caffeine remains verified Apache-2.0 (its own POM on Maven Central at 3.2.4, the version the Quarkus BOM resolves), shipped transitively via `quarkus-caffeine` before it was ever declared here — nothing needed waiving. The replacement note records when that input *is* appropriate: a package whose licence GitHub reports wrongly, naming the licence being accepted.

### Files

* `.github/workflows/dependency-review.yml` — broadened `deny-licenses`, removed `allow-dependencies-licenses`; rewrote the comments to record the decision, the evidence, and the revisit condition (upstream announcing removal, or GitHub resolving BOM-managed Maven coordinates).

## ⬆️ chore(ui): Node 22 toolchain, Stryker 10, Vitest 5 for the Chat UI (2026-09-17)

**Repo:** EDDI (`feat/node-22-toolchain`, stacked on `fix/ui-npm-vulnerabilities` / #770)

Node 20 reached end of life on 2026-04-30, and it was what held the UIs on Stryker 9 and Vitest 4: Stryker 10 dropped Node 20 (Dependabot #768 was red for that reason) and Vitest 5 requires ≥ 22.12.

### What changed

* **Node 20 → 22 everywhere the build names a version.** `pom.xml` `node.version` `v20.20.2` → `v22.23.2` (the latest 22.x; Maintenance LTS until 2027-04-30), `mise.toml` to match, and all eight `actions/setup-node` steps in `ci.yml` (`node-version: 22`, step names too). `AGENTS.md` and `README.md` no longer say Maven downloads Node 20. No test asserts on the version and no Dockerfile uses Node.
* **Manager: Stryker `9.6.1` → `10.0.0`** (still exact-pinned). Its only breaking change is the Node floor. The `typed-rest-client` → `qs` override stays: Stryker 10 still takes `typed-rest-client` `~2.3.0`.
* **Chat UI: Vitest `^4.1.11` → `^5.0.1`.** No test or config change was needed.
* **Manager stays on Vitest 4.1.11 — see Decisions.** `dependabot.yml` now ignores Vitest/`@vitest/*` *majors* for `/ui/manager` only, with the reason and the upstream issue beside the rule (`update-types` scopes it to version updates; security updates still arrive).
* **The UIs' own docs caught up** (Copilot review): `ui/chat/README.md` and `ui/chat/AGENTS.md` still said Node ≥ 20, Vitest 3 and react-markdown 9.x; `ui/manager/README.md` still said Node ≥ 20. A contributor following them would install an unsupported runtime. Each now names the floor that applies to that UI — 22.12 for the Chat (Vitest 5), 22.18 for the Manager (Stryker 10's Babel 8) — and the pinned 22.23.2.
* **`updates.test.ts`: a test for the two cleanups in `getWithoutCredentials`'s `finally`.** Stryker 10 mutates more statements than 9.6.1 (265 mutants on `updates.ts` against 263), and both new ones survived: deleting `clearTimeout(timer)` or `csp.stop()` failed no test. The existing "stops listening for violations once the request is done" test cannot see that leak — each request reads its own closure's flag, so a leftover listener never touches the next verdict, it only accumulates. The new test pins both calls (spies on add/removeEventListener and set/clearTimeout) and fails with either line deleted.

### Decisions

* **The Manager cannot take Vitest 5 yet: it breaks Stryker.** On Vitest 5, `@stryker-mutator/vitest-runner` 10.0.0 selects zero tests per mutant — its per-test filter joins names with a space, Vitest 5 joins them with `' > '` ([stryker-mutator/stryker-js#6210](https://github.com/stryker-mutator/stryker-js/issues/6210), open, no fixed release on npm). Measured here: `updates.ts` scored **0.00** (all 257 covered mutants "survived") against 81.85 on Vitest 4 with the same runner and Node. The break threshold would at least fail the run, but a gate whose every mutant survives measures nothing. The Chat UI has no Stryker, so it moves. Revisit the Manager when a fixed runner ships — the Dependabot ignore rule names the issue.
* **The real Node floor is 22.18, not 22.12.** Stryker 10 moved to Babel 8, whose packages declare `engines.node` `^22.18.0 || >=24.11.0`. The `pom.xml` comment records it; on an older 22.x Stryker warns `EBADENGINE` and may not run.
* **22, not 24.** 24 is Active LTS until 2028-04-30, but this change was scoped to leaving the EOL line. Every package in both lockfiles declares an `engines.node` range that also accepts 24.21.0, so moving on later is a pin change, not a migration.

### Verification (on Node 22.23.2 — the binary Maven downloads)

* `./mvnw package -DskipTests` installed Node v22.23.2 and ran `npm ci` + `npm run build` for both UIs: BUILD SUCCESS.
* Manager (Vitest 4.1.11, Stryker 10): lint, typecheck, `vitest run --coverage` — 411 files, 6,515 tests, thresholds met. Scoped `stryker run --mutate src/lib/api/updates.ts`: **83.78** (216 killed of 265), above `thresholds.break` 82 — 81.85 before the new cleanup test, 82.49 on Stryker 9.6.1.
* Chat (Vitest 5.0.1): typecheck, 278/278 tests. A static sweep found none of Vitest 5's removals in use (`.sequential`, non-top-level `vi.mock`, removed `vitest/*` entry points, `toThrow('')`), and its new `clearMocks: true` default broke nothing.
* `npm ci` for both lockfiles in a `node:22.23.2` Linux container. The Windows `@emnapi` pruning did not recur; all four entries are present. `npm audit`: 0 vulnerabilities in both UIs.

***

## 🔒 fix(security): a token with no principal name fails closed — 401 up front, 403 in the ownership checks (2026-09-17)

**Repo:** EDDI (`fix/nameless-principal-fail-closed`)

### Problem

Measured against `labsai/eddi` with OIDC enforced and a Keycloak realm whose access tokens carried no `upn`, `preferred_username` or `sub`: Quarkus authenticates the token but resolves the principal name to `null`. Two consequences followed.

* `OwnershipValidator.validateAndResolveUserId` returned that `null` as the resolved userId, and `ConversationSetup.computeAnonymousUserIdIfEmpty` then stamped an authenticated user's conversation with a random `anonymous-<hex>` owner the user could never read back.
* `GET`/`POST /agents/{conversationId}` by a non-admin owner answered **500**: `requireOwnerOrAdmin` called `callerId.equals(...)` on the null. `validateUserAccess`, `validateAndResolveUserId` and `isOwner` had the same unguarded `getName().equals`, as did the group conversation owner filters in `RestGroupConversation.listGroupConversations` and `McpGroupTools.list_group_conversations`.

The shipped realm is fixed separately (`fix/keycloak-realm-client-scopes`); this change is for every other identity provider or hand-built realm that can produce the same token.

### What changed

* **`NamelessPrincipalAugmentor`** (new, `engine/security`) — a `SecurityIdentityAugmentor` that fails authentication (`AuthenticationFailedException` → **401**) for a non-anonymous identity whose principal is missing or whose name is null or blank. It logs a `[SECURITY]` WARN naming the claims it looked for (the configured `quarkus.oidc.token.principal-claim`, else `upn`/`preferred_username`/`sub`) and the token's issuer and `azp`, log-sanitized, never the token. A misconfigured realm fails every request, so the WARN is throttled to once per five minutes and the rest log at DEBUG. Anonymous identities pass untouched.
* **`OwnershipValidator`** — a public static `principalName(identity)` normalises a missing principal and a null/blank name to `null`. Every check denies a nameless non-admin with **403** instead of NPE-ing: `isOwner` is `false`, `validateUserAccess`/`requireOwnerOrAdmin` (and so `requireOwnerOrAdminStrict` and `requireOwnerAdminOrApprover`) throw `ForbiddenException`, and `validateAndResolveUserId` throws rather than resolving to `null`.
* **The legacy-owner exemption no longer admits a nameless caller** (Copilot review on PR #773). `requireOwnerOrAdmin` returned for an unowned resource *before* it looked at the caller's name, so a nameless non-admin still reached legacy conversations and group conversations through it. It now resolves the name first and only then applies the exemption; `ConversationAccessGuard.canAccessConversation`, which must admit exactly what that check admits, uses the new `OwnershipValidator.isNamelessCaller` to hide unowned conversations from the same caller. Anonymous and admin callers are unchanged.
* **Group conversation listings** (`RestGroupConversation`, `McpGroupTools`) use `principalName` and return nothing for a nameless caller — including legacy rows with no owner, which a null-to-null comparison would otherwise have matched.
* **`docs/security.md`** — new "The Token Must Name the User" section: the claims Quarkus reads, the 401 and its WARN, and the two fixes (add the claim, or set `QUARKUS_OIDC_TOKEN_PRINCIPAL_CLAIM`).

### Design decisions

* **Reject at authentication, and keep the 403s.** The augmentor is the legible failure: one 401 and one WARN that say what is missing, rather than a 500 on one endpoint, a silently orphaned conversation on another and an empty list on a third. The null-safe validator is defence in depth for any identity that reaches a resource without passing through the augmentor (a future auth mechanism, a test identity).
* **401, not 403.** The token is not *forbidden* something; it cannot identify the caller at all, which is an authentication failure. The Manager and Chat UI treat 401 and 403 alike, so there is no redirect loop.
* **Admins keep their role-based bypass in the validator.** `requireOwnerOrAdmin` and an admin naming an explicit `userId` are authorized by the role, not the name. An admin with *no* `userId` to resolve is still denied — there is nobody to file the conversation under. In practice the augmentor rejects the admin's nameless token first.
* **No escape hatch.** A nameless identity cannot own anything, so there is no configuration in which letting it through is useful; the remedy is `quarkus.oidc.token.principal-claim`.
* **Background paths are unaffected.** Schedule fires, group members and sub-agents never authenticate a request, so they never reach the augmentor, and the validator's `identity == null` branches are unchanged.
* **Other principal reads left alone.** `HitlAccessGuard`, `RestConnectionAuthorization`, `RestConnectionSettings`, `A2ATaskHandler`, `OpenAiAuthFilter`, `SpaceContext`, `RestScheduleStore`, `CallerIdentityContext` and `RestAgentEngine` already null-check the name; the audit stamps in `RestAgentEngine` (`endedBy`, `cancelledBy`) and `RestGroupConversation` (`submittedBy`, `decidedBy`) record `null` rather than failing, which the augmentor now makes unreachable.

### Tests

* `OwnershipValidatorTest` — new `nameless principal` nested class: every method with a null, empty and whitespace name (plus an identity with no principal), admin and non-admin (32 cases).
* `NamelessPrincipalAugmentorTest` (new) — named and anonymous pass through; null/blank/missing principal fail with `AuthenticationFailedException`; the diagnostic names the default or configured claim and the sanitized issuer/client; the WARN throttle.
* `RestGroupConversationTest`, `McpGroupToolsTest` — a nameless caller lists nothing, including an unowned row.
* `OwnershipValidatorTest`, `ConversationAccessGuardTest` — a nameless caller is refused an unowned resource by `requireOwnerOrAdmin`, `requireConversationOwner` and `canAccessConversation`; an admin is not. With the reordering reverted, 5 of these fail.
* **Mutation-checked:** with `OwnershipValidator` and both listings reverted to `origin/main` and the augmentor short-circuited, 22 of the new tests fail (NPEs, missing 403s, missing 401s). The blank-name cases that pass against the old code do so because a blank name never equalled a real owner; they stay as regression guards.
* **Not run end to end against a live Keycloak.** That a failing augmentor answers 401 was checked in the Quarkus 3.39.3 sources instead: `QuarkusIdentityProviderManagerImpl` chains the augmentors into the authentication `Uni`, and `HttpSecurityRecorder.DefaultAuthFailureHandler` answers an `AuthenticationFailedException` with the mechanism's challenge (401). No unit test covers that HTTP step.

## 🔒 feat(context): secret context values — usable for one turn, never stored or returned (2026-09-17)

**Repo:** EDDI (`feat/secret-context-values`)

### Why

A client that needs the agent to call a downstream API as the signed-in user has to hand EDDI that user's credential, and context is the only channel for it. But `Conversation` stores every context entry as a step datum and echoes it in the conversation output, so the credential landed in `conversationmemories`, in every detailed response and conversation read, and — once a property setter copied it — in the properties and the user memory store. `scope: "secret"` does not fit a per-user, per-request credential: the vault key is `agentId + "." + propertyName`, one slot per agent, so concurrent users overwrite each other's value. `secretInput` only hides the typed message. `${caller:token}` only covers calls back to EDDI's own origin.

### What changed

* **`Context.secret`** (`Boolean`, nullable so ordinary entries serialize unchanged). The client marks the entry: `{"type": "string", "value": "…", "secret": true}`. The flag lives on the value because the sender knows what is sensitive; putting a key list in the agent configuration would couple every agent to one client's field names.
* **`Conversation`**: a secret entry is never put into the echoed `context` output, not even mid-turn. The step datum keeps the live value while the pipeline runs, so templates, HTTP call headers and behavior rules work. When the pipeline stops — completed, stopped, paused or failed — the new `scrubSecretContextValues()` runs first in `executeConversationStep`'s `finally`, before the audit flush, the longTerm write and the stored snapshot. It replaces the entry with `MemoryKeys.SECRET_CONTEXT_PLACEHOLDER` (`<secret context>`) and every copy of the value in properties (in place, so their step mirrors follow), other step data (result and possible results) and the conversation output. Objects the plain walk cannot enter (output items, records) are scrubbed through their JSON form, using the persistence mapper configuration, and replaced by the scrubbed tree — so a reply that echoed the value is stored and returned as `[{"type":"text","text":"Token was <secret context>"}]`, keeping its shape. Only an object that cannot be converted falls back to whole replacement (WARN).
* **`TurnAuditBuffer.flush(memory, secretContextValues)`** redacts those values from every buffered entry, independent of the secret-input redaction.
* **`ApiCallExecutor.rejectExpiredSecretContext`**: an HTTP call whose resolved header, query parameter, body or path (also URL-encoded) still carries the placeholder is refused with an error naming the location — the case of a call that runs after a HITL approval resumed the turn, or a later turn that references the value. Same fail-loudly pattern as unsatisfiable `${caller:…}` references.
* **`SecretValueScrubber`** (new, `engine.memory`): the string/list/map/Context walk extracted from `PropertySetterTask` (which now uses it), plus `scrubDeep` for the JSON-form scrub and `scrubTyped` for values that must keep their type. Map keys carrying a secret are scrubbed with the values; a number is replaced when its string form equals a secret; longest-first replacement is enforced inside the utility instead of relying on the caller's order (review follow-ups).
* **HITL tool-call pauses**: the persisted pending batch (`argumentsRaw` used by the resume, the redacted arguments, request previews, transcript) is scrubbed with the step, so a secret context value does not survive a pause there either. A resumed HTTP tool call then hits the expired-value refusal below.
* **Fire-and-forget batch HTTP calls build every request on the turn's thread** and only send in the background. A request that cannot be built — expired secret context value, unsatisfiable `${caller:…}` or `${connection:…}` reference — now fails the turn like a single fire-and-forget call does, instead of being logged by a worker while the turn reports success. Behaviour change for configs whose batch build fails today: that failure becomes visible.
* Docs: `passing-context-information.md` (new "Secret Context Values" section) and a pointer in `secrets-vault.md`.

### Design decisions

* **Scrub at the end of the turn, not at read time.** Tasks need the plaintext while they run; what must not happen is the value outliving the request. A HITL resume therefore sees the placeholder — documented: send the value again with the request that needs it.
* **Values shorter than 8 characters are only removed from their own entry**, not searched for elsewhere, so a short value cannot wreck unrelated output. Longest values are replaced first.
* **Separate placeholder from `<secret input>`**: that one on `input:initial` is how the audit ledger decides the INPUT was a secret.

### Verification

* `ConversationSecretContextTest` (10), `SecretValueScrubberTest` (10), `ApiCallExecutorSecretContextTest` (6) and a new `TurnAuditBufferTest` case; the engine audit/memory/runtime, properties and apicalls suites plus the repo-wide guards stay green. The review follow-ups (pending batch, numbers, map keys, scrub order, audit numbers) were each mutation-checked.
* Mutation-checked: removing the end-of-turn scrub, the output masking, the audit redaction, the property scrub, the possible-results scrub or the JSON-form scrub each fails at least one test.
* End to end on the packaged build (real MongoDB, mock API that echoes the header back): the HTTP call sent the real token in its header; the say response, the conversation read, the stored document and the server log did not contain it; the saved API response and the reply showed the placeholder with their shape intact; a control run without the flag stored the token as before (12/12).

### Not covered

* The value still reaches anything a template sends out of EDDI while the turn runs: a prompt (model provider), a reply streamed over SSE (the returned and stored reply is scrubbed), or a query parameter/body (written to the server log by the HTTP call task). Documented: use it in headers only.

***

***

## 🔒 fix(ui): clear the 30 npm advisories Scorecard reports (2026-09-17)

**Repo:** EDDI (`fix/ui-npm-vulnerabilities`)

OpenSSF Scorecard's *Vulnerabilities* check reported 30 open advisories. All 30 were npm and all were in the two UI lockfiles that arrived with the monorepo migration (`ui/manager`, `ui/chat`); none were in `pom.xml` or `ui/manager/.ds-sync`. Every one is a devDependency (test runner, bundler, Stryker and their transitives), so nothing affected ships in the jar — but Scorecard counts them regardless.

### What changed

* **vitest `^3` → `^4.1.11` in both UIs** (plus `@vitest/coverage-v8` in the Manager). This is the only fix line for GHSA-82fw-gwwq-j7x9 (`@vitest/mocker` path traversal): no 3.x backport exists. Chat also moves `vite` to `^6.4.3` (GHSA-fx2h-pf6j-xcff, GHSA-v6wh-96g9-6wx3) and clears the critical GHSA-5xrq-8626-4rwp, which its locked vitest 3.2.4 was still exposed to.
* **Stryker `9.2.0` → `9.6.1`** (still exact-pinned) — drops the old `minimatch`/`ajv`/`@babel/core` chain.
* **`overrides` → `typed-rest-client` → `qs: ^6.16.0` in the Manager.** `typed-rest-client@2.3.1` (via `@stryker-mutator/core`) pins `qs` to exactly `6.15.1`; no 2.x release relaxes it and Stryker 10 still takes `~2.3.0`, so an override scoped to that one parent is the only fix (GHSA-4mjr-xmp4-gh2g, GHSA-x5fp-wj9c-mxmx). `typed-rest-client@3` itself requires `qs ^6.16.0`.
* **The remaining transitives** (`browserslist`, `fast-uri`, `js-yaml`, `minimatch`, `brace-expansion`, `nanoid`, `postcss`, `ws`) were refreshed in place in the lockfiles.
* **Four Manager test files fixed for Vitest 4's mocking changes** (one tsc error, 29 failures):
  * `bearer-event-source.test.ts` — `ReturnType<typeof vi.spyOn>` no longer carries `fetch`'s parameter types; typed as `MockInstance<typeof fetch>`.
  * `infinite-scroll-sentinel.test.tsx` — a `vi.fn` called with `new` must now be a `function`, not an arrow.
  * `workforce-coverage-2.test.tsx` (24 failures) — the ExportMenu tests spied on `document.createElement` and never restored it. Vitest 3 stacked the second spy on the first; Vitest 4 returns the *same* mock, so the captured "original" was the mock itself — infinite recursion, and the leaked spy broke every later describe. Now restored after each ExportMenu test.
  * `use-operator-chat.test.tsx` — `restoreAllMocks` no longer resets `vi.fn()`s created in `vi.mock` factories, so call history leaked across tests (a "called once" assertion saw 43). `resetAllMocks` added beside it, which is what `restoreAllMocks` used to do for those mocks. The other 11 files calling `restoreAllMocks` were checked: none holds a module-scope `vi.fn`, so none can now pass on history left by an earlier test.
* **Manager coverage floors recalibrated: lines 85 → 83, statements 85 → 81** (`vitest.config.ts`). Not a relaxation. Vitest 3's `v8-to-istanbul` counted every source *line* as a statement, so JSX markup, which runs on every render, padded both figures — `main` measured 90.25 / 90.25. Vitest 4 remaps against the AST and counts real statements: the unchanged suite reads 83.45 % lines and 81.83 % statements. The *uncovered code* is identical — `view-toggle.tsx` was flagged at lines 23–33 under both — only the denominator moved. Branches (84.03 → 76.44) and functions (74.29 → 76.37) still clear 75 / 70 and were left alone. The new floors sit under half a point below the measurement, far tighter than the \~5-point slack the old ones had; whether that slack should be restored is open.
* **A real bug the upgrade surfaced: the vault popup closed itself** (`secret-key-picker.tsx`, own commit). With a canonicalisable value such as `vault:jira-client-secret` in a reference-only picker, the popup focuses its filter 50 ms after opening; that blurred the input, blur canonicalised the value into a chip, and the chip state renders no popup, so it vanished right after opening. The existing test only passed because it asserted before the timer fired; under Vitest 4 on a loaded run it failed 2 of 3 times. `handleBlur` now ignores focus moving into the popup (scoped to the popup, not the whole picker: tabbing on to the vault button still canonicalises, which two existing tests pin). A new test waits for the filter to take focus and fails with the fix reverted. Deferring that blur made every way *out* of the popup responsible for normalising instead (Copilot review): Escape and the opener button hand focus back to the input, whose own blur then normalises; clicking away or tabbing out of the popup normalises directly. A blur with no `relatedTarget` is deliberately ignored — that is what picking a key looks like, and normalising the stale value there would overwrite the key just picked. Four tests, one per path, each mutation-checked; they assert the chip by structure, because the popup lists the same key as an option and a text match alone passed with the popup still open. A fifth exit, found by CodeRabbit: **"Create new secret"**. It closes the popup and opens the modal, so focus leaves the field and the deferred blur never comes — cancel the dialog and the unbraced reference was stranded. Normalising as the dialog *opens* (the suggested fix) cannot work: it switches the picker to its chip state, which returns before the modal is rendered, so the dialog would never appear. It normalises on *close* instead, and only when the user cancelled — `onSuccess` runs before `onClose` without a re-render in between, so an unconditional normalise there would write the old value over the key just created. Both halves are pinned by a test and each fails under the mutation the other guards.
* **Dependabot: `vitest` + `@vitest/*` (both UIs) and `@stryker-mutator/*` (Manager) are grouped.** These packages peer-depend on each other at the exact same version, so a single-package bump can never pass `npm ci`. That is precisely what happened: #766 (vitest 4.1.11), #768 (Stryker 10) and the security-updates groups #762/#764 were all red with `ERESOLVE`, and #762 would have left vitest on 3.2.6, still vulnerable. This branch supersedes all four.

### Decisions

* **Vitest 4, not 5.** Vitest 5.0.1 is `latest`, but it (and Stryker 10) requires Node ≥ 22.12, while `pom.xml` pins `node.version` v20.20.2 and every UI job in `ci.yml` uses Node 20. 4.1.11 fixes the advisory on the Node the build actually runs. Node 20 reached end of life in April 2026, so the Node 22 move — and with it Vitest 5 / Stryker 10 — is the follow-up.
* **Vite stays on 6** (6.4.3 carries the fixes); Vite 7/8 are a separate migration.
* **Windows lockfile pruning, again.** `npm install` on Windows dropped `@tailwindcss/oxide-wasm32-wasi`'s `@emnapi/core` and `@emnapi/runtime` from the Manager lock (see `ui/manager/AGENTS.md`); both were restored from `main`'s lock. Both lockfiles were then proven with `npm ci` in a `node:20` Linux container.

### Verification

* OSV ranges for all 30 advisories evaluated against every `packages` entry of the three lockfiles: 41 affected instances (30 distinct IDs) on `main`, 0 on this branch. `npm audit` is clean in all three.
* Chat: typecheck, 278/278 unit tests, build. Manager: lint, typecheck, i18n check, all 411 test files (6,515 tests) with coverage (the same 411 `main` collects — Vitest 4 narrowed its default `exclude`, but this config sets its own), build.
* Stryker 9.6.1 on Vitest 4 (the runner gained Vitest 4 support in 9.3.0): a scoped run over `src/lib/api/updates.ts` completed at 82.49 %, above `thresholds.break` 82.
* The production dependency tree is untouched: no non-dev entry changed in either lockfile. Every transitive major (chai 6, `@inquirer/*` 5, zod 4, …) is inside the Vitest 4 or Stryker 9.6 trees.

***

## 🔐 fix(auth): the shipped realm gives tokens an identity again (2026-09-17)

**Repo:** EDDI (`fix/keycloak-realm-client-scopes`)

### What was broken

Since 6.1.0, `eddi-realm.json` has defined one client scope, `openid`. A realm file that defines any client scopes gets only those: Keycloak creates its built-ins solely for realms that define none, and logs `Referenced client scope 'profile' doesn't exist. Ignoring` for each missing reference. So `eddi-frontend` lost `profile`, `email`, `roles`, `web-origins` and `acr`, and never had `basic`. Tokens authenticated and carried their roles (the client maps those itself), but had no `sub`, `preferred_username`, `name` or `email`. Measured against `labsai/eddi:ci` and Keycloak 26.7:

* the backend's principal had no name, so `GET /workspaces` reported no principal and every conversation was stamped `anonymous-<hex>`;
* a non-admin opening or continuing **their own** conversation got HTTP 500: `OwnershipValidator.requireOwnerOrAdmin` called `equals` on a null `callerId`;
* the Manager's avatar showed "?" (worked around separately on `fix/manager-avatar-no-claims`).

With the fix the principal is the username (`eddi`, `user`), the stored owner is that username, and the owner reads and continues their conversation with 200 while another non-admin gets 403. Releases before 6.1.0 had the built-in scopes, so their principal was already the username: nothing to migrate.

### What changed

* **All three realm copies** add `basic`, `profile`, `email`, `web-origins` and `acr`, copied verbatim from a stock Keycloak 26.7 realm minus server-generated ids. `eddi-frontend` lists `openid, basic, profile, email, web-origins, acr`, and the realm's `defaultDefaultClientScopes` says the same, so a client an operator adds later issues identity claims too. `openid` stays: it puts `openid` in the `scope` claim of a token that did not request it (any direct grant), and Keycloak's userinfo, which the backend calls (`user-info-required=true`), refuses a token without it. `roles` is dropped from the list rather than defined: it never existed on import, and the client's own mappers already emit `realm_access.roles` and the `eddi-backend` audience.
* **`install.sh` repairs existing realms** (import is one-shot). `repair_keycloak_identity_scopes` creates any missing scope from the realm file and attaches it to `eddi-frontend`. It runs from `configure_keycloak_client` on a fresh setup and from a new `repair_running_keycloak` in `main()`'s already-running branch, which is how an existing installation is re-run: that path skipped every setup step, so it detects auth from `.eddi-config`, reads Keycloak's port from `.env` (quotes and CRLF tolerated), reads the scope definitions from a fresh copy in a temporary file (the realm file on disk, which an operator may have edited, is never touched; a proxy's HTML page just produces a warning), and re-applies nothing else (CORS origins depend on ports that run may not be given). `basic`, `profile` and `email` are attached unless the client has them as a default or optional scope; `web-origins` and `acr` only when this run created them, so an operator who detached them is respected. Idempotent, removes nothing, and every pipeline assignment carries `|| var=""` under the installer's `set -euo pipefail`. Verified in `bash:3.2` against Keycloak 26.7 and 26.0.8 (what the compose files and charts ship): a 6.1–6.4 realm on the jq and python3 paths and through the already-running path (5 created, 5 attached), a pre-6.1 realm (`basic` attached), an operator's detached `web-origins` and optional `email` (untouched), a no-auth install (silent), malformed JSON (survives; the unguarded form exits), and a second run of each (no-op). `eddi update` does not run it. `install.ps1` has no Admin API step at all, so its users and Helm/Kustomize operators get a documented one-time repair in `docs/security.md` (a subshell with `set -eu`, so pasting it cannot close the terminal; fails loudly on a bad login or missing scope, strips the CRLF a Windows `jq.exe` emits), run verbatim on 26.0.8 twice through a CRLF-emitting jq and once with a wrong password, and pasted into a live shell after an unset or wrong password. The docs tell a custom-port install to re-run the installer with the same `EDDI_PORT`, since the installer recognises a running EDDI only on that port. Known gap: a run that creates `web-origins`/`acr` and fails before attaching them leaves them unattached on later runs (they carry no identity). Both test-user lists there stop claiming `eddi`/`eddi` and a forced password change: `eddi` ships with no password, and Keycloak 26 forces no change on import.
* **Guards.** `DeploymentManifestsTest` gains three: every referenced client scope is defined in the same file; the SPA client's mappers emit `sub`, `preferred_username`, `name` and `email` into the access token and it keeps `openid`; and `install.sh`'s repair loop matches the realm file. The auth E2E tier gains five: claims for all three fixtures without a scope parameter, `GET /workspaces` naming the admin, and a non-admin opening the conversation they started (whose cleanup undeploys with `endAllActiveConversations=true`; without it a live conversation made undeploy answer 409 and leaked the agent). The installer guard also pins the already-running path and the docs loop. Mutation-checked: against the old realm all three unit guards fail and 7 of 12 E2E tests fail (the 5 new ones plus the two role tests, which now also assert `preferred_username`); with the fix, 12/12 on Keycloak 26.7 and twice on 26.0.8 with no agent left deployed, and the class passes apart from three `create-secrets.ps1` tests that fail identically on a clean `origin/main` in this environment.

***

## 🎨 fix(manager): the user-menu avatar no longer shows "?" (2026-09-17)

**Repo:** EDDI (`fix/manager-avatar-no-claims`)

### What changed

* **`ui/manager/src/lib/user-display.ts` (new)** derives the avatar's initials and the menu label from whatever claims the token carries: given + family name, then the display name's first and last word, then the first letter or digit of the username, then of the email's local part. When none yields a character it returns `""`, and `TopBar` and `Sidebar` render a `UserRound` icon instead of the literal `"?"` they used to print. The label falls back to a new `auth.signedIn` key ("Signed in", all 11 locales), and the email line is not repeated when the email is the only label available. The top-bar trigger also gained a visible keyboard focus ring.
* **Review follow-ups.** Initials are the first *letter or digit* of each part, NFC-normalised, so punctuation and emoji no longer become initials ("Doe, Jane (Contractor)" used to give "D("); Thai and Lao preposed vowels are skipped; two Arabic initials get a zero-width non-joiner so they do not join into a word; and `toUpperCase` replaces `toLocaleUpperCase`, which followed the browser's locale rather than the app's (a Turkish system turned "isabel" into "İ"). A username with no letter now falls through to the email. The helper documents why initials prefer given + family name while the label prefers the display name. `userSecondaryEmail` hides the email case-insensitively when it is already the label; truncated name and email lines carry a `title`; the collapsed sidebar avatar is `role="img"` with the user's name as its label and tooltip (expanded, it is `aria-hidden`, since the name is printed beside it); French reads "Session ouverte".
* Tests: `user-display.test.ts` (18), plus the no-claims token shape in `top-bar.test.tsx` and `sidebar.test.tsx`. Mutation-checked: restoring the `"?"` fallback fails three of them.
* **CI: a UI-only pull request no longer reports "Build Failed" to Slack.** `notify-slack` required `build-and-test` to be `success`, but that job is skipped by design on a pull request touching neither the backend nor the operator docs, so this PR's run was classified a failure with nothing failed and tried to post. A skip now counts as passing only in exactly that case; a skip on push or tag, a cancel or a failure still fails. Checked against a seven-case truth table. Separately, the webhook itself answers HTTP 4xx (`curl` exit 22, also on a genuinely failed run on 2026-09-16), so the job stays red on any real failure until the `SLACK_WEBHOOK_URL` secret is replaced.

### Why the claims were empty

The shipped realm (6.1.0 through 6.4.0) defines only the `openid` client scope, so Keycloak never created `profile`, `email` or `basic`, and its tokens carry no `preferred_username`, `name`, `email` or `sub`. That is fixed at the source, with the backend consequences it had, on `fix/keycloak-realm-client-scopes`. This change stays useful after it: realms provisioned by hand, other identity providers, and users without a name or email still reach the fallback.

***

***

## ⚡ perf(monorepo): the efficiency review follow-ups (2026-09-15)

**Repo:** EDDI (`chore/monorepo-migration`) — the follow-ups from the two-reviewer efficiency review recorded in the monorepo entry below.

### What changed

* **The backend Playwright tiers drive the bundle that ships.** In `e2e-fullstack` the integration and full-stack tiers now run with `PORT=7070 E2E_AGAINST_BACKEND=1`: `playwright.config.ts` then starts no Vite dev server and every page load goes to the app EDDI serves out of the image — the hashed chunks, the multi-page shells, `/manage/__auth_config__.js` — instead of a dev-mode build of the same source. (`main.tsx` only falls back to mocks in a dev build, so the full-stack tier cannot silently run on MSW this way.) Local runs without the variable are unchanged.
* **`OpenAPI Snapshot`, a new job, checks the Manager's snapshot without a container.** `Build Image` passes `-Dquarkus.smallrye-openapi.store-schema-directory=target/openapi` and uploads the document; the job compares `ui/manager/src/test/mocks/openapi-operations.json` with it (`refresh-openapi-operations.mjs` reads `OPENAPI_FILE`) and uploads the regenerated file when it differs. A job of its own rather than a step in `Build Image`, so a stale snapshot does not withhold the image from the E2E tiers; `E2E Gate` and `docker` require it. The runtime check in `e2e-fullstack` stays on the MongoDB leg and now also guards that the served document agrees with the stored one.
* **The MSW Playwright tier runs with two workers in CI** (`npm run test:e2e -- --workers=2`); the config keeps one worker for the backend tiers, whose specs share state in serial groups.
* **Dependabot npm entries** gain a cooldown (3 days, 14 for majors; security updates are never delayed) and a `security-updates` group, so open advisories arrive as one PR per ecosystem.
* **`README.md`, `AGENTS.md` and `.githooks/**` leave the `code` filter for `backend`.** Only unit tests read them, so a change to only those files now runs `Build & Test` and nothing that builds, scans or publishes an image. `BuildQualityGatesTest` checks the root documents against `backend` and keeps `backend` equal to `code` without `ui/**`, plus `ui/**/*.md` and those three.
* **`Integration Tests` no longer re-runs the \~20k unit tests.** A new `skipUTs` property (it follows `skipTests`, so `-DskipTests` still skips everything) skips surefire alone. `Build & Test` uploads `target/jacoco.exec`; `Integration Tests` restores it and the surefire reports before `verify -DskipITs=false -DskipUTs=true`, so the merged 90/80 coverage gate grades the same data as before. `BuildQualityGatesTest` fails if the job skips the unit tests without that hand-off.

### Decisions

* **Rejected: Vitest `css: false`.** Measured in a `node:20` container on the full Manager suite: 301 s against 285 s with CSS processing on, and it broke a real test (`export-dialog.test.tsx` › "toggle all checkbox selects and deselects resources").
* **Two Playwright workers, measured before adopting:** 234 MSW tests passed twice at two workers, in 5.4 and 5.8 minutes, with no flaky results, against 13.1 minutes at one.
* **The Dependabot keys were validated against the published schema** (`json.schemastore.org` `dependabot-2.0.json`, checked with ajv, which rejects a deliberately invalid `cooldown` key) — an unrecognised key invalidates the whole file and silently stops every update in it.

### Verification

All on 2026-09-15, locally (Windows 11, JDK 25.0.1) unless marked.

* **Shipped-bundle E2E (follow-up 1).** The image built from this branch under the Manager's MongoDB compose file, with `PORT=7070 E2E_AGAINST_BACKEND=1`: API integration **44/44 in 12.5 s**, full stack **35/35 in 45 s**, and no Vite dev server started. The same tiers through the dev server had taken 7.6 minutes, a retried serial group included.
* **Build-time OpenAPI document (follow-up 2).** `refresh-openapi-operations.mjs` with `OPENAPI_FILE` pointed at the document a `clean package` stored gives exactly the 344 operations of the snapshot taken from the running backend ("No change"): the stored document is a faithful substitute for a booted backend.
* **Playwright workers and Vitest CSS (follow-up 3).** In a `node:20-bookworm` container: the MSW tier at two workers passed 234/234 twice (5.4 and 5.8 min, no flaky tests); Vitest with `css: false` took 301 s against 285 s and failed one test, so it was not adopted.
* **Dependabot (follow-up 4).** The edited file validates against the published schema; both npm entries carry the cooldown and the `security-updates` group.
* **Coverage hand-off (follow-up 6).** `package -DskipTests` still reports "Tests are skipped". With a unit-test `jacoco.exec` restored into a clean `target`, `verify -DskipITs=false -DskipUTs=true` skipped surefire, the unit-test report loaded the restored file (1,263 classes, no class-mismatch warning), and the `merge` execution loaded both `jacoco-it.exec` and `jacoco.exec` into `jacoco-merged.exec`. The trial's single IT (`LogAdminIT`) could not boot Quarkus on this machine — Netty could not open a selector, "Unable to establish loopback connection", the environmental failure recorded in the monorepo entry below — so that build stopped before `merged-check`. The 90/80 evaluation on the full suites is first seen in this PR's CI run.
* **Backend guard tests** on the final state (177, including the changed `backend`-filter tests and the new coverage hand-off test): only the 3 environmental PowerShell failures recorded below.
* **Not verifiable locally:** the workflow graph itself — the new `OpenAPI Snapshot` job, the artifact hand-offs between jobs, and how Dependabot applies the cooldown.

### Fixed after PR #757 opened

The first CI run on the PR reported three findings that no local check could have seen, all of them in code the import brought in unchanged:

* **`Dependency Review` and `Trivy Filesystem Scan` failed on `ui/manager/.ds-sync/package-lock.json`**, the Manager's design-sync tooling: `brace-expansion@5.0.7` (via `ts-morph` → `minimatch`) carries GHSA-mh99-v99m-4gvg and GHSA-rgw5-rvv9-x895, both high. It lists `ts-morph` as a runtime dependency, so both scanners grade it; the Chat and Manager lockfiles are clean at production scope. Bumped to 5.0.12 inside `minimatch`'s `^5.0.5` range — the lockfile diff is that one entry; the nested `@emnapi` entries npm on Windows drops were restored. That directory also gets its own Dependabot entry (validated against the schema), so the lockfile does not go stale again.
* **The `CodeQL` result check** reported four alerts once TypeScript was scanned for the first time: `js/remote-property-injection` in `use-current-screen-context.ts`, `js/missing-origin-check` in MSW's generated `mockServiceWorker.js`, `js/http-to-file-access` in the `refresh-openapi-operations.mjs` dev script, and `js/empty-password-in-configuration-file` on `helm/eddi/values.yaml` — the JavaScript extractor reads YAML across the whole repository. The UI analysis is now scoped by `.github/codeql/codeql-ui.yml` to `ui/manager/src` and `ui/chat/src`, the code that ships, in both `ci.yml` and the scheduled `codeql.yml` (the file records what is left out and why), and `.github/codeql/**` joins the `code` and `backend` filters. The hook alert is a false positive — its keys come from the static route table — but `toContextPayload` now accepts only the known context keys and builds the payload with `Object.fromEntries`, with a test that `constructor`, `__proto__` and unknown keys are dropped. `password: ""` in the Helm chart is the documented "required, no default" setting, unchanged.
* Verified locally: the `.ds-sync` production audit is clean; the hook's 14 tests, the Manager lint and typecheck pass; both workflows parse, and `backend` still equals `code` without `ui/**` plus the test-only entries.

### Auth E2E tier, and the role mapping it found broken

Asked whether the full backend E2E really passes, whether it passes on PostgreSQL, and whether anything covers Keycloak. The first two: yes. The third found a production bug.

**PostgreSQL is clean.** Ran the built image against `ui/manager/docker-compose.integration-postgres.yml` locally: 44 API integration tests and 35 full-stack browser tests, every one passing first try, no retries. CI runs MongoDB only on pull requests and both stores on `main`, so this leg had never actually executed on this branch.

* One local-only flake surfaced on the way: `chat page loads and allows agent selection` failed when the tier ran wide. `fullyParallel: true` applied to the backend-facing tiers too, and they share one EDDI and one datastore — so one spec undeployed the agent another was asserting on. CI never saw it because `workers: 1` serialises everything there. `fullyParallel` is now off at the top level and re-enabled on the mock-backed `ui` tier alone, so a local run behaves like CI.

**Nothing had ever exercised an authenticated request.** Every backend-facing compose file sets `EDDI_SECURITY_ALLOW_UNAUTHENTICATED=true`, which ties `authorization.enabled` to false and makes every `@RolesAllowed` a no-op. So a new tier: `ui/manager/docker-compose.integration-keycloak.yml` (EDDI + MongoDB + Keycloak 26, OIDC enforced), a Playwright `auth` project, and a CI job `Auth E2E (Keycloak)` folded into the E2E Gate.

* **What it found, on its first run: the administrator the shipped realm seeds is refused everything.** Quarkus OIDC reads roles from the `groups` claim whenever that claim is present and never falls back to `realm_access`. EDDI already owns `groups` — workspaces resolve `team:<group>` spaces from it (`WorkspaceSettings.eddi.workspaces.groups-claim`, same default). And `helm/eddi/files/eddi-realm.json` — byte-identical to `k8s/overlays/auth/eddi-realm.json` — puts the seeded `eddi` administrator in the `engineering` group. So installing the chart with `keycloak.enabled=true` and giving `eddi` a password as NOTES.txt instructs produced an administrator who authenticated and was then denied every endpoint, `/administration/*` included. The unprivileged fixtures belong to no group and behaved correctly, which is why nothing showed it. Fixed with `quarkus.oidc.roles.role-claim-path=realm_access/roles`.
  * Verified by experiment, not by reading: with the property, `eddi` gets 200 on `/agentstore/agents/descriptors` and `/administration/orphans`; without it, 403 on both while the token plainly carries `realm_access.roles=[eddi-admin, eddi-editor]`. Mutation-checked against the unfixed image: exactly one test fails, the administrator one, with the other six green.
  * **Pre-existing, not caused by the migration** — the backend and the chart realm both predate it. The migration is only why it was finally executed.
* **The Manager's committed Keycloak realm was dead code that could not have worked.** `ui/manager/keycloak/eddi-realm.json` named the client `eddi-manager` while the backend hardcodes `eddi-frontend` into `/manage/__auth_config__.js`, and its roles were `admin`/`editor`/`viewer` against a backend that enforces `eddi-admin`/`eddi-editor`/`eddi-user`/`eddi-viewer`/`eddi-approver`. Deleted. Both the dev-aid compose and the new tier now mount the chart's realm, so there is one realm in the repo that anything runs against and it cannot drift again.
* The tier's realm is **generated, never committed** (`scripts/make-test-realm.mjs`): it reads the chart realm and adds a throwaway password for `eddi`, which the shipped realm deliberately omits. Every assumption it makes — the client id, its public/direct-access flags, each fixture's roles and passwords, and that `eddi` still ships credential-less — is asserted, so a change to the shipped realm fails the script by name instead of leaving the tier testing nothing.
* Issuer handling is the part that usually breaks: the tests fetch tokens through the published port on `localhost:8180` while EDDI validates against `http://keycloak:8080/realms/eddi` on the compose network. `KC_HOSTNAME` pins the issuer to the compose-network URL regardless of the request's Host header, so both sides agree; without it every token would claim an issuer EDDI rejects and the tier would report 401 for reasons unrelated to the code under test.
* Coverage is API-level on purpose — which token is accepted and which role opens which door — rather than driving Keycloak's login form, whose failures are mostly its own. Seven tests: the SPA's auth config, anonymous 401, malformed-token 401, the admin's claims, the admin allowed through, and `user`/`viewer` authenticated but refused.

### Copilot review findings on PR #757

Four unresolved threads, all confirmed against the code before being fixed. CodeRabbit skipped the PR entirely (1345 files against a 100-file limit) and Codacy reported nothing, so these were the whole review.

* **`-DskipUi=true` could still ship a UI.** The flag skips the npm build and the `copy-ui-bundles` execution, but skipping a copy cannot undo one: a plain `./mvnw package` followed by `./mvnw package -DskipTests -DskipUi=true` in the same `target/` left the first run's bundles in `target/classes` and packaged them, so the documented "the jar then serves no UI" was not what you got. `maven-clean-plugin` gains a `drop-stale-ui-bundles` execution on `prepare-package` that deletes the thirteen generated paths from `target/classes/META-INF/resources`, leaving the three backend-owned files (`index.html`, `robots.txt`, `scripts/js/landing-redirect.js`) alone. It runs unconditionally rather than only under `skipUi`, because a UI *rebuild* has the same bug in miniature: Vite content-hashes its filenames, so every repackage without a `clean` shipped the new `assets/index-<hash>.js` beside the old one.
  * The plugin moved above `maven-resources-plugin` in the POM — executions of one phase run in declaration order, and the drop has to land after the UIs build and before the copy — and both filesets moved from the plugin onto their executions (`default-clean` keeps the source-tree list). A plugin-level `<configuration>` merges into *every* execution, and had the `clean`-phase list reached this one it would have deleted `ui/manager/dist` moments before the copy read it.
  * Verified: `./mvnw clean:clean@drop-stale-ui-bundles` against a seeded `target/classes` removes exactly the generated paths and leaves `target/`, `ui/manager/dist` and the three backend files; `./mvnw clean` still clears the source tree and both `dist/` directories.
* **`Preflight Dry-Run (PR)` never ran for a UI-only pull request.** It listed `build-and-test` in `needs` although it consumes only Build Image's `eddi-ci-image` artifact. `build-and-test` gates on `backend`, which is false when a PR touches only `ui/`, and GitHub skips a job whose dependency was skipped *before* evaluating its `if` — so the job's own `code == 'true'` condition never got a say and the image that PR would publish went uncertified. Now `needs: [detect-changes, build-image]`. This is the same trap `UI Gate` and `E2E Gate` exist to avoid on the publish path, so `BuildQualityGatesTest` now grades it for every job: a pull-request-reachable job gated on `code` alone may not wait on `build-and-test` without `always()`. Mutation-checked — restoring the old `needs` fails the test naming `preflight-check`.
  * That broke `Build & Test` on the push: `DeploymentManifestsTest` still required both `sbom` and `preflight-check` to need `build-and-test`, so "don't publish untested code" was pinning the very dependency that skips the job. The assertion now holds for `sbom` alone, which uploads; for `preflight-check`, a dry run that pushes only to a registry inside the job, it requires `build-image` and forbids `build-and-test`. The local guard run had missed it because it named its test classes by hand; the re-run covered every test that reads `ci.yml`.
* **`.github/dependabot.yml` was in no filter a Java test reads.** `BuildQualityGatesTest` parses it to check the Docker ecosystems it declares stay in step with `base-image-check.yml`'s skip logic; Dependabot's own check validates the schema, not that contract. A PR changing only that file resolved `backend=false` and skipped the one test that owns it. Added to the `backend` filter alongside `README.md`, `AGENTS.md` and `.githooks/**`, and to the parity assertion.
* **The published SBOM described only the Maven half.** Both UIs are built into the jar, so their npm production dependencies are part of the shipped supply chain, and `cyclonedx-maven-plugin:makeBom` inventories Maven only. The `sbom` job now also runs `@cyclonedx/cyclonedx-npm` for `ui/manager` and `ui/chat` with `--package-lock-only` (reads the lockfile, so no `npm ci` and no install scripts) and `--omit dev`, which is what Vite actually bundles. Verified locally: valid CycloneDX 1.6, 192 components for the Manager and 134 for the Chat UI.
  * Three documents are uploaded rather than one merged BOM. Merging across ecosystems needs `cyclonedx-cli`, a GitHub-release binary we would have to fetch unverified, and hand-rolling the metadata/bom-ref/dependency-graph merge is how you get a plausible but invalid BOM. Each file is valid on its own and Dependency-Track, Red Hat certification and grype all ingest a set.
  * `ui/manager/.ds-sync` is deliberately not inventoried: it is design-sync tooling that never reaches the image.

## 🧩 chore(monorepo): EDDI-Manager and EDDI-Chat-UI move into this repository as ui/manager and ui/chat (2026-09-15)

**Repo:** EDDI (`chore/monorepo-migration`) — executes `planning/monorepo-migration-plan.md` (PR #670, Revision 3).

**Merge this PR with "Create a merge commit" — never squash.** The imported histories are the reason the import exists; a squash flattens 1,275 commits into one.

### What changed

* **History import (§5.1).** `labsai/EDDI-Manager` `main` @ `0870ae87` (1,213 commits) and `labsai/EDDI-Chat-UI` `master` @ `71fa395` (62 commits) were rewritten with `git filter-repo --to-subdirectory-filter` on bare clones and merged with `--allow-unrelated-histories` — not `git subtree add`, which loses path-scoped `git log`. Verified: both imported trees are blob-for-blob identical to the source commits, no tags were imported (55 before and after), `git log -- ui/manager/package.json` returns 81 commits and blame shows the original authors. Gitleaks (v8.30.1, the CI version) over both rewritten histories: 0 findings, so no `.gitleaksignore` entry had to land on `main` first.
* **Post-import fixups.** The Manager's helper scripts moved from `.github/scripts/` to `scripts/`. Two of them computed their root as `../..` for the old depth, which would have written the OpenAPI snapshot one directory too high — fixed, with the two tests that import them. `.github/` (CI now lives in the root `ci.yml`), husky/lint-staged, `renovate.json`, the `deploy-to-local-eddi-repo` scripts and the Chat's tracked `dist/` are gone. The Manager lockfile was pruned of husky/lint-staged **by hand**: `npm uninstall` on Windows also drops the nested `@emnapi/*` entries that Linux `npm ci` requires.
* **The UIs build with Maven (§6–§7).** `manage.html`, `welcome.html`, `workforce.html` moved into `ui/manager` as Vite multi-page inputs sharing one hashed bundle; the Chat builds to `dist/` instead of `../EDDI/src/main/resources`. `frontend-maven-plugin` runs `npm ci` + `npm run build` for both in `prepare-package` (Node 20.20.2 vendored into `ui/node/`) — so `compile`, `test` and `quarkus:dev` never touch npm, while `package`, `verify` and `install` always build the UI — and `copy-ui-bundles` copies both `dist/` trees into the jar — without the MSW worker and without ever overwriting `index.html`, `robots.txt` or `landing-redirect.js`. 756 generated files are no longer tracked; `src/main/resources/META-INF/resources` holds exactly those three hand-written files. The `.gitignore` block, the default-resource excludes and the `maven-clean-plugin` fileset list the same 13 paths, so a stale pre-migration bundle on disk can neither be committed, nor copied into the jar, nor survive `./mvnw clean`. `-DskipUi=true` skips all of it.
* **Backend tests.** Five tests read the committed shells or assets and would have failed once those stopped being committed. The four resource tests now read stand-in shells under `src/test/resources/META-INF/resources`; `StaticAssetCachingTest` drops the `.manager-assets` manifest check (the orphaned-bundle problem it guarded against is gone by construction); the content-hash check on the built assets lives in `Build Image`, where those assets are produced.
* **CI (§8).** New jobs: `UI Manager Checks`, `UI Manager E2E (MSW)` and `UI Chat` (the Manager's former CI plus its MSW Playwright tier, and the Chat's typecheck and tests, as three parallel jobs), `UI Gate`, `Build Image` (jar + both UIs + image, built once with an npm cache and passed on as an artifact, after checking the four shells, the content hashes and the Chat bundle), `Backend E2E (mongodb|postgres)` (the Manager's API and full-stack Playwright tiers against the image built from the same commit, a check that every same-origin dependency of the four shipped shells answers 2xx, and a blocking OpenAPI snapshot check), `E2E Gate`, `CodeQL Analysis (UI)`. `Build Image` also asserts that the packaged `assets/` set equals what Vite just emitted: `copy-resources` never prunes, and a local build without `clean` really did package 913 assets against 737 built. `docker` no longer builds: it publishes the tested image. `preflight-check` certifies the same image. On a pull request `Build & Test` and `Integration Tests` gate on a new `backend` filter — `code` without `ui/**`, plus the UI markdown the documentation tests walk, kept equal by `BuildQualityGatesTest` — so a Manager, Chat or npm Dependabot PR skips the Java suite; on push and on tags they gate on `code`, so nothing publishes untested. `pom.xml` is not in the `ui` filter: the UI jobs never run Maven. The OpenAPI snapshot check runs on the MongoDB leg only and uploads the regenerated file when it fails.
* **Housekeeping (§9).** Dependabot npm entries for both UIs (replacing Renovate, carrying its react-router major-version block); the scheduled CodeQL scans TypeScript; the Manager's compose files take `EDDI_IMAGE` and set the two `HighValueSurfaceGuard` opt-outs whose absence had kept the Manager's backend E2E red since 2026-08-26; one Node pin in the root `mise.toml`; AGENTS.md, README, CONTRIBUTING and the Manager/Chat docs describe the new layout.

### Decisions and deviations from the plan

* **Fixes landed here, not as preparatory PRs in the source repos.** The Chat's `react-router` 7.13.1 → 7.18.3 bump (two high advisories, six HIGH Trivy findings) and the compose opt-outs. Every gate that reads them (dependency-review, Trivy, Scorecard, the E2E job) reads this PR's head, and the source repos are about to be archived.
* **`CodeQL Analysis (UI)` is a separate job, not a language matrix on `codeql`.** A matrix renames the required `CodeQL Analysis` check, which would wedge every PR on a context that never reports.
* **`Build Image` labels the image by event.** A pull request gets the bare pom version (what the PR preflight certifies and asserts), a push the tag it is published under — exactly what the two builds it replaces did.
* **The OpenAPI snapshot check is blocking on PRs too.** In the Manager repo it was advisory there because the backend image tracked `latest`; here the backend is built from the PR.
* **Efficiency review (two independent reviewers, Fable 5 and Opus 5).** Both found the same costs: every local `compile`/`test`/`quarkus:dev` rebuilt both UIs (and deleted `node_modules` under a running `npm run dev`), UI-only PRs ran the \~20k-test Java suite, Maven Dependabot PRs ran the UI job, and the UI job ran Manager and Chat serially. Fixed as above. Rejected: defaulting `skipUi` to true (a local `package` — `install.sh --local`, `mise run docker-build` — would then silently build an image without a UI), and frontend-maven-plugin's incremental build options (its `npm` goal has none in 1.15.1 — checked in the plugin descriptor).
* **Phases 2 and 3 are one commit.** Deleting the committed bundles before Maven builds them would leave a commit whose jar serves a blank `/manage`.
* **The Chat `typecheck` script is now `tsc -b --noEmit`.** `tsc --noEmit` against its solution-style `tsconfig.json` checked nothing; plain `tsc -b` would have emitted into `dist/`.
* **Not ported:** the Chat branch `fix/release-6.2-polish` (two commits, never merged to `master`, never shipped). Port with `git format-patch` + `git am --directory=ui/chat` or drop it. The Manager's Stryker `mutation.yml` is not carried over (§13).

### Verification

All run locally on 2026-09-15 (Windows 11, JDK 25.0.1) unless marked Linux.

* **Import.** Both imported trees blob-identical to the source commits; 55 tags before and after; path-scoped `git log`, `--follow` and blame show the original commits. Gitleaks over both rewritten histories: 0 findings.
* **`-DskipUi=true`** (`clean package`): no frontend execution ran and `target/classes` holds no UI.
* **Dirty workspace, `package` without `clean`**, with stale `assets/index-STALE00.js`, `manage.html`, `chat.html`, `mockServiceWorker.js`, `img/loading-indicator.svg` and a `chat-ui.*.js` planted in `src/main/resources/META-INF/resources`: none reached `target/classes`, and both shells there are the freshly built ones — the default-resource excludes hold without help from `clean`.
* **`./mvnw clean`** deleted every planted file and both `dist/` trees and kept `index.html`, `robots.txt` and `landing-redirect.js`.
* **Full build:** `copy-ui-bundles` copied 742 Manager and 11 Chat files; 737 hashed assets; all three Manager shells load the same `assets/main-<hash>.js`; no `mockServiceWorker.js`, no root `logo_eddi.png`, no pre-migration `index-*` entry; the bundle carries `EDDI Demo 6.4.0` from `EDDI_VERSION`.
* **Linux (`node:20-bookworm`), the `UI Build & Test` steps from a `git archive` of the branch:** Manager `npm ci` (so the hand-pruned lockfile satisfies Linux npm), `audit:prod`, lint, i18n check, typecheck, Vitest with coverage, build, the build-output check, and all 234 Playwright MSW tests; Chat `npm ci`, typecheck, tests and build, with no stray `ui/EDDI`. All green.
* **Backend guard tests** (`StaticAssetCachingTest`, the four `Rest*ResourceTest`s, `BuildQualityGatesTest`, `ReleaseVersionSourceTest`, `DocumentationLinksTest`, `DocumentationAccuracyTest`, `ImportStyleTest`, `ComposeStackTest`, `DeploymentManifestsTest`, `StrictBoundaryShippedConfigsTest`, `RuleSetStoreShippedRulesetsTest`, `ChangelogRotationTest`, `DocumentedRestPathsTest`, `ConfigurationReferenceCoverageTest`, `DemoImageDockerfileTest`), run after every review fix and with this entry in place: 176 run, 3 failures — all in `DeploymentManifestsTest`'s PowerShell `create-secrets.ps1` cases, which fail identically on an untouched `origin/main` worktree on this machine (environmental).
* **Environmental, not this change:** locally `quarkus:build` ends with "Unable to establish loopback connection" on untouched `origin/main` too — it is the build-analytics ping; builds here pass `-Dquarkus.analytics.disabled=true`.
* **The image built from this branch** (`labsai/eddi:ci`), booted under the Manager compose files on a shifted host port, on **both MongoDB and PostgreSQL**: healthy within seconds; the `Verify the shipped shells` step, extracted verbatim from `ci.yml`, passed on both (487 same-origin dependencies of the four shells answer 2xx, the entry chunk carries the immutable `Cache-Control`, `mockServiceWorker.js` and a root `logo_eddi.png` answer 404).
* **OpenAPI snapshot against that backend: drifted**, exactly as the new blocking check is meant to catch. Refreshed in this commit (+8 operations: resource sharing, `/workspaces`, connection settings; −2: `GET /chat` and `GET /chat/{path}`, now hidden from the OpenAPI document), which made five `EXEMPT` entries in `openapi-contract.test.ts` stale; they are removed and the contract test passes.
* **The Manager's Playwright tiers against that image on MongoDB** (compose file as CI uses it, port 7070): API integration **44/44**. Full stack **34 passed, 1 failed** on the first run — the card test in `resources-crud.fullstack.spec.ts` still clicked the pre-v6 `resource-type-behavior` id, which `resources.tsx` no longer renders. Untouched Manager `main` (`0870ae87`) against the same image fails the same test, so it predates this change: the tier had not been able to boot a backend since 2026-08-26 and never reached it. Fixed here, together with the spec's outdated known-failure header (with `60188c2bd` in the image all six resource types list). The spec runs in serial mode, so the one failure had retried the whole group twice; after the fix it passes **9/9 in 20 s**.
* **Not run locally:** the Playwright tiers on PostgreSQL (the image boots there and passes the shipped-shell step, above) and any of it on a GitHub runner — the first signal is this PR's own run.
* **Efficiency changes:** `./mvnw clean compile` and a `test` run executed **zero** frontend steps; `clean package` ran surefire, then all five frontend steps in `prepare-package`, then `copy-ui-bundles`, then the jar (737 assets, and the build-time OpenAPI document in `target/openapi`); the new `Verify the built and packaged UI` step, extracted from `ci.yml`, passes on that output; the guard tests (176, including the new `backend`-filter drift test) show only the 3 environmental failures above.
* **Not verifiable locally:** the CI graph itself (job skips, artifact hand-off, fork PRs, required checks).

### Outside this repository (plan §9.5, §10) — still to do

* Branch protection: require `UI Gate` and `E2E Gate` (both always report; the jobs behind them are path-gated); keep `CodeQL Analysis` and `Build & Test` as they are.
* Do **not** enable GitHub's CodeQL default setup. It is `not-configured` (checked with `gh api …/code-scanning/default-setup`); every analysis comes from `ci.yml`, and turning it on would make GitHub reject the workflow's own uploads and fail the required `CodeQL Analysis` check. `CodeQL Analysis (UI)` already scans the TypeScript.
* After the first green `main` pipeline including Backend E2E: pointer READMEs, close the open PRs (Manager #72, #95, #140, #168, #207; Chat #19–#24, #26) with a link here, archive both repos.
* Local: `EDDI.code-workspace`, stale copies of the deploy scripts; run `./mvnw clean` once.

## 🏷️ chore: refresh README badges and set the project domain to eddi.technology (2026-09-15)

**Repo:** EDDI (`chore/deps-and-version-6-4-0`)

* `README.md` — the tests badge said `14,000+`; the last full unit run on this branch was 20,221 tests, so it now reads `21,000+`. The coverage badge names both gates, `>90% instr / >80% branch`, matching the figures AGENTS.md already quotes.
* `application.properties` — `systemRuntime.projectDomain` moves from `eddi.labs.ai` to `eddi.technology`. Its only consumer is `HttpClientWrapper`, which builds the outbound User-Agent from it, so outbound calls now identify as `EDDI.TECHNOLOGY/<version>`. The other `eddi.labs.ai` references (OpenAPI contact URL, banner, Dockerfile image label, docs) are left as they are in this change.

## 🔀 chore(merge): origin/main into the 6.4.0 dependency branch (2026-09-15)

**Repo:** EDDI (`chore/deps-and-version-6-4-0`)

`origin/main` @ `13e8feb73` (PR #751, the connections review findings) merged in. Only this file conflicted, and only because both sides added entries at the top: every entry from both sides is kept, main's newer 2026-09-14 entries above this branch's dependency and release entries. No other file was changed on both sides — the Manager UI assets and `pom.xml` bumps are this branch's alone.

## 🔁 fix(connections): the fourth PR #751 review round, with main merged in (2026-09-14)

**Repo:** EDDI (`fix/connections-review-findings`)

**Merge.** `origin/main` @ `52d031940` (PR #750, the 6.4.0 E2E fixes, and PR #746) merged in. Only this file conflicted: both sides' entries are kept, as are both sides' Decision Log rows. The merged file was over the 250 KB cap, so `scripts/rotate-changelog.py` moved 12 entries into the existing `docs/changelog/2026-08.md` (no new archive, so `docs/SUMMARY.md` is unchanged).

**Review findings** — the open thread and the two outside-diff findings from the round-3 review body:

* **A reshaped connection still had its code redeemed.** `boundConnection` checked only that the name still belonged to the bound id, so a connection switched to `OAUTH2_CLIENT_CREDENTIALS` or `SERVICE` while the user sat on the consent screen had the authorization code exchanged, a refresh token minted and stored, and only then discarded by the post-write re-read. `RestConnectionAuthorization.callback` now refuses with `exchange_failed` before the exchange unless the document is still a `PER_USER` authorization-code connection (`takesPerUserGrants`, shared with `connectionStillTakesTheGrant`). The post-write re-read stays for an update landing after that check. Tests: `shapeChangedBeforeTheExchangeRedeemsNothing`; `grantStoredUnderAShapeTheConnectionNoLongerHasIsDeleted` now reshapes the connection inside the write window.
* **A stale callback's discard could delete a newer grant.** `discardGrant` deleted by `(tenant, name, principal)`, so a later link of the same account that replaced the grant between the write and the discard was removed with it. New `IConnectionGrantStore.deleteIfSealedWith` deletes only while the row still carries the access-token IV of the write being taken back (Mongo: filter on `accessTokenIv`; Postgres: `AND access_token_iv = ?`; the in-memory double mirrors it). `OAuthTokenService.persistNew` now returns the grant it wrote so the callback can name it. A `null` IV matches nothing — in Mongo `eq(field, null)` would also match a row without the field. Tests in `MongoConnectionGrantStoreTest`, `PostgresConnectionGrantStoreUnitTest`, `ConnectionGrantStoreCasTest` and every discard test in `RestConnectionAuthorizationCallbackTest` (which now also assert no keyed `delete`).
* **Docs:** `docs/connections.md` described plaintext-origin enforcement in terms of the property, which a stored `allowPlaintextRemoteOrigins: true` makes wrong. Both places now say "the effective `allowPlaintextRemoteOrigins` setting".

**Decision:** the discard is keyed on the IV rather than on `version`. `upsert` does not report the version it wrote, and making it do so means `findOneAndUpdate` / `RETURNING` on both backends for one caller. The IV is random per seal, so it names the write; its one blind spot is a DEK re-seal in the milliseconds between write and discard, where the delete misses and the grant stays until disconnect or connection deletion — the safe direction.

***

## ⚙️ feat(connections): runtime connection settings — no restart, properties pin (2026-09-14)

**Repo:** EDDI (`fix/connections-review-findings`, PR #751) · Manager counterpart on `feat/connection-settings`

The four deployment settings of the connections feature — `enabled`, `publicBaseUrl`, `credentialEndpointAllowlist`, `allowPlaintextRemoteOrigins` — were properties only, so turning the feature on or approving one more OAuth provider meant a restart, and enabling it without a base URL refused the boot outright. They are runtime settings now, written through `PUT /connectionstore/settings` and read back with their provenance.

**Why the properties-only argument did not hold.** It rested on "an operator, not an administrator, approves where a client secret may go". Nothing else in EDDI draws that line: `eddi-admin` writes the vault, and an httpcall header resolves any `${vault:…}` and sends it to any host (`ApiCallExecutor` resolves vault references in headers, query, body and URL with no destination binding), which `eddi-editor` may also author. A properties-only allowlist therefore cost a restart and protected nothing from an administrator. What it *does* still need protecting from — an LLM, an imported agent, a connection document vouching for itself — is kept out by where the endpoint is exposed, not by a restart.

**Design.**

* **Precedence: pinned → stored → default.** A property or environment variable that is set *pins* its value: it wins, reads as `PINNED`, and a `PUT` that would change it is a **409** naming the property. That keeps the operator/administrator split available to a deployment that genuinely has one. Restating the pinned value, or omitting it, is accepted. Every default fails closed. The four property lines in `application.properties` are now commented out, because an uncommented line would pin.
* **No seeding.** A pinned value is never copied into the store, and a pinned field's previously stored value is carried forward untouched — so removing the property later falls back to what an administrator stored, not to a silent copy of the old pin.
* **Freshness.** `ConnectionsConfig` caches the stored document for 5 s (it is read on hot paths — every resolution asks about plaintext origins). The writing instance adopts its own write immediately; others see it within the TTL. A store read failure keeps the last values; before the first successful read only pins and defaults apply.
* **Validation at the write boundary** (`RestConnectionSettings`): the base URL must be a bare https origin (dev/test: loopback http), allowlist entries are canonicalised and de-duplicated, and a *remote plaintext* allowlist entry is refused — a credential endpoint must be https or loopback before a secret is sent, so it would approve nothing. The shape rules live once in `ConnectionSettingsRules`, shared with the boot and request-time checks.
* **The boot no longer refuses a missing base URL.** Only per-user OAuth linking needs it, and `POST /connections/{name}/authorize` now answers **400** naming the setting. A *pinned* base URL of the wrong shape still refuses the boot — it is operator configuration that only a restart changes.
* **Exposure.** `eddi-admin` only (`IRestConnectionSettingsRoleGateTest`). Not an MCP tool, not in export/import or Agent Sync, and excluded from the Platform Operator's write scope (pinned by a Manager test). The response carries the `redirectUri` to register at each provider and warnings for a configuration that saves but will not fully work. Each change is logged at INFO with the principal and the before/after values (none is a secret).
* **One document per tenant**, Mongo `connection_settings` (`_id = tenantId`) and Postgres `connection_settings` (nullable columns, `TEXT[]` allowlist); `tenantId` is `"default"` until multi-tenancy supplies one. Whole-document replace, last write wins.

**Files.** New: `connections/settings/` — `ConnectionSettings`, `ConnectionSettingsRules`, `ConnectionSettingsView`, `IConnectionSettingsStore`, `MongoConnectionSettingsStore`, `PostgresConnectionSettingsStore`, `IRestConnectionSettings`, `RestConnectionSettings`. Changed: `ConnectionsConfig` (pinned/stored/default resolution, cache), `CredentialEndpointAllowlist` (reads the effective allowlist per call), `ConnectionStartupGuard` (pinned-only refusal, warning otherwise), `RestConnectionAuthorization` (`requirePublicBaseUrl`), messages in `ConnectionResolver`, `RestConnectionStore` and `ConnectionConfiguration` naming both handles, `DataStoreProducers`, `application.properties`, `docs/connections.md`, `docs/configuration-reference.md`. Tests: `ConnectionsConfigStoredSettingsTest`, `RestConnectionSettingsTest`, `ConnectionSettingsRulesTest`, both store tests, the role gate; `ConnectionStartupGuardTest` and `RestConnectionAuthorizationCallbackTest` updated for the new boot and authorize behaviour.

**Review round (Fable 5.1 review + a live boot against MongoDB).**

* **Unauthenticated writes are refused outside dev/test** while `authorization.enabled=false` (403), unless `eddi.connections.settings.allow-unauthenticated-writes=true`. The rationale "properties-only protected nothing from an administrator" is right for administrators, but on a deployment without OIDC `@RolesAllowed` is a no-op — and the shipped compose files run that way — so an *anonymous* caller could otherwise approve an origin for a client secret. Same narrow per-surface opt-out shape as `HighValueSurfaceGuard`; adding the path to that guard instead would have failed every existing unauthenticated boot.
* **A stored value hidden behind a pin is surfaced** (`Setting.shadowedStoredValue`) with a warning. Removing a restrictive pin brings a permissive stored value back; that must never be invisible.
* **Enabling at runtime runs the stored-connection report** (`ConnectionStartupGuard.reportStoredConnections`) that a boot with the feature off skipped. A store unreadable at boot says the report was skipped.
* **Restating a pinned value is compared before strict validation and normalised**: a trailing slash or scheme case on the base URL, and an operator-pinned plaintext allowlist entry, no longer produce a 409 or 400.
* **Postgres `updated_by` is `TEXT`** (an OIDC principal can exceed 255 characters); `Array.free()`.
* **Serialization contract.** EDDI omits null fields (`NON_NULL`), so an unset `value`, `redirectUri`, `updatedAt` or `updatedBy` is absent from the JSON, not `null`. Documented on `ConnectionSettingsView`; the Manager types and fixtures follow it.
* **Upgrade note**: a property set even to its default now pins (configuration-reference).

**Not done / open.** Cross-replica invalidation is TTL-only (no event bus). Changes are logged, not written to the audit ledger — the ledger is conversation-task shaped. The Manager's OpenAPI snapshot exempts the two new operations until it is refreshed against a backend that has them.

***

## 🔁 fix(connections): the PR #751 review round — claims, clocks, export, plaintext and redaction (2026-09-13)

**Repo:** EDDI (`fix/connections-review-findings`)

Twenty-two review comments on PR #751 (CodeRabbit, Copilot, CodeQL, code quality), all verified against the code and all valid. One commit per finding, each with a regression test. Two of them correct fixes recorded in the entries below: R7 bound the contender's JVM clock, which only moved the skew, and C3 enforced name uniqueness with scans that could not see across replicas.

**Uniqueness and races.**

* **A durable name claim** (`10962ccab`). `IConnectionNameClaimStore` keeps one claim per (tenant, name) under a MongoDB unique index or a PostgreSQL unique constraint. A create claims first and answers 409 for a live holder; a stale claim (crashed create, failed release) is taken over by compare-and-set. The create records its id against its token, then writes the descriptor, and a descriptor failure is now a failed create. Connections that predate claims are found by the one remaining name scan and backfilled. The unused multi-holder scan, `IConnectionStore.idsOfName`, is removed.
* **Grants linked during an authType/binding change** (`e5db9c0a8`). A count before the update is not atomic with an OAuth callback. Both sides now re-check after their own write — the update counts again and deletes what appeared, the callback re-reads the connection uncached and discards its grant if the shape changed — so one of the two always sees the other.
* **The refresh lease uses the database clock on both sides** (`22f153147`): `CURRENT_TIMESTAMP AT TIME ZONE 'UTC'` on PostgreSQL, `$$NOW` on MongoDB. `claimRefresh` takes a duration. The lease is released when the claimant's re-read fails (`9cbbb83c2`).

**Credentials in transit and at rest.**

* **Plaintext remote origins need an opt-in** (`e0bf28a9b`): `eddi.connections.allow-plaintext-remote-origins`, default `false`. Refused at save (400), per request (`TARGET_NOT_ALLOWED`) and reported at ERROR at boot. Loopback http is always allowed.
* **A plaintext token URL is refused before the client secret is sent** (`1adb43c22`); https, or http to a loopback host, shared with save-time validation.
* **Non-admin reads are redacted** (`414982f3d`). An editor reading a connection gets `clientSecret`, `passwordRef`, `valueTemplate` and each `extraAuthParams` entry only if it passes the write-time rule; a legacy literal is replaced by a marker. Admins see the stored document so they can fix it.
* **Log lines sanitize user-controlled values** (`a9f87b184`, plus the four refresh-path lines in `OAuthTokenService`), closing CodeQL `java/log-injection`.

**Export, import and grants checking.**

* Export authorizes VIEW on every referenced connection and refuses without it (`2d85bce21`); only a dangling reference is skipped, every other failure fails the export (`c35005ceb`).
* `strategy=upgrade` imports the archive's connections too (`3b65c91cf`), and an import that cannot account for a connection it created (no resource URI, no descriptor) fails and rolls it back (`5c602dd91`).
* The vault-grant check expands `${vars:}` in the connection's own tenant (`5735c021d`) and before the connection scan as well as the vault scan (`5a86bbc98`).
* A malformed stored origin no longer aborts the startup report (`6a8b73ac9`); the allowlist docs no longer mention discovery endpoints (`3cdf0780a`); two mocked `ResultSet` stubs no longer read as leaks (`830acb20e`).

**Decision — export refuses rather than skips** a connection the caller cannot view. An archive quietly missing a connection imports into an agent whose references do not resolve, and nothing says why.

**Decision — the claim is a separate store, not an index on the config document.** The versioned document store cannot carry a unique index on a field inside the document.

**Upgrade notes.**

* Connections with a remote `http://` origin stop resolving until the new property is set or the origin moves to https.
* MongoDB 4.2+ is required (`$$NOW`); the project documents 6.0+.
* New storage, created lazily: collection or table `connection_name_claims`.
* A duplicate name on create answers **409**, not 400.
* During a rolling upgrade, a replica without claims can still race a create briefly.

**Not changed:** CodeRabbit's docstring-coverage pre-merge warning, which counts every touched function; the project documents behaviour at class and non-obvious-method level.

**Second round.** The push drew nine more threads from CodeRabbit, CodeQL and GitHub code quality.

* **The OAuth callback is bound to the connection's id, not its name** (`bcddbb4a2`). A connection deleted and re-created under the same name while the user was on the consent screen received a grant issued for its predecessor's client, and its own allowlist decided where that token went. The state row now carries the connection id (a MongoDB field; a PostgreSQL `connection_id` column added with `ADD COLUMN IF NOT EXISTS`). The callback exchanges nothing unless the name still resolves to that id, reads the connection by id uncached rather than from the name-keyed registry, and after storing the grant re-checks id and shape and discards it on a mismatch.
* **A method CodeQL read as a permission check is renamed**, `requirePlaintextOriginsPermitted` to `refusePlaintextRemoteOriginsUnlessAllowed`. `java/tainted-permissions-check` matches any one-argument method whose name contains "permitted"; this one validates input, and who may write is `@RolesAllowed("eddi-admin")`. Renamed rather than dismissed, so no alert needs an admin's judgement.
* **The PostgreSQL store tests assert resource closing** (`f7be4653f`). The code-quality leak findings pointed at Mockito stubbing expressions, which acquire nothing, and last round's restyling only moved the warning. The tests now check that every connection, statement and result set the name-claim and grant stores open is closed, on the failure paths too.
* **The in-memory grant store validates the refresh lease first** (`192895d29`), as both real stores do; it used to leave a claim with no expiry behind on a null lease.
* **The plain-text variable test proves expansion ran** (`fbe3bd213`).

**Upgrade note:** an account link started before the upgrade and finished after it is answered `invalid_state`, because its state row carries no connection id. States live ten minutes; the user starts the link again.

**Third round.** Two more threads. CodeRabbit found that `authorize` still built the consent URL from the name-keyed registry cache while taking the connection id from the store, so right after a delete and re-create a replica could send the user to the predecessor's consent screen and fail at the callback. `authorize` now reads the connection by id at its current version, uncached, and uses that one document for validation, the state and the URL; the registry it no longer reads is dropped as a dependency. A further code-quality "leak" on a mocked `ResultSet` in the name-claim store test is the same false positive as round two; that test already asserts the store closes it.

**Companion:** labsai/EDDI-Manager#208 answered its own review round (a complete reference before the chip, retries only on network/5xx, the name grammar in references, Retry through the `Button` primitive).

***

## 🔐 fix(connections): runtime and security findings from the connections review (2026-09-13)

**Repo:** EDDI (`fix/connections-review-findings`)

Thirteen findings from the code review of the connections feature, runtime and security side (the config/store/docs findings are on a sibling branch). One conventional commit per finding; every fix carries a regression test that fails without it.

**Credentials that leaked or went missing.**

* **R1 — connection-owned headers persisted in plaintext.** `RequestRedactor` recognised a credential only by conventional header name, a `${vault:` marker, or value shape. A `STATIC` connection on `X-Amp-Id` or a `CALLER_SUPPLIED` one on `X-Acme-Key` matched none, so the live value was written to MongoDB and shown to a HITL approver. `buildRequest` now returns the header names a connection filled, and both the persisted request map and the approval preview redact them unconditionally, case-insensitively. MCP and A2A persist no request headers.
* **R2 — no `CallerIdentity` on turn 0.** `startConversation` bound the `ResolutionPrincipal` around the synchronous CONVERSATION\_START turn but not the caller, so `${caller:token}` and every `CALLER_SUPPLIED` connection failed closed on turn 0 with advice about scheduled runs. The identity is captured once, binds the start turn, and the previous binding is restored (nested starts).
* **R5 — principal not propagated to cascade/batch threads.** `callerIdentityContext.propagate` carried the caller only; a `PER_USER` connection inside an agent-mode cascade step or a fire-and-forget batch was refused. `ResolutionPrincipalContext` gains `propagate`/`withPrincipalSupplying`, and `CallerIdentityContext.propagate` composes both — the single helper mid-pipeline dispatches use, so the next binding is added there. The explicit `withIdentity(...)` sites stay caller-only: they dispatch from request threads where the member conversation binds its own principal, or compose with `withPrincipal` themselves (the HITL resume).

**The token endpoint.**

* **R3 — redirects followed with the body.** `sendValidated` re-implements redirect following (method and body preserved on 307/308) and validates the hop against SSRF rules only. A token request carries the client secret and the refresh token or code, so an allowlisted endpoint answering 307 re-sent all of it elsewhere, while docs and Javadoc claimed otherwise. `SafeHttpClient` gains `sendValidatedNoRedirect` and `sendNoRedirect`; any 3xx from a token endpoint is `TOKEN_ENDPOINT_UNAVAILABLE` with a message saying a token endpoint must not redirect.
* **R4 — transient failures marked terminal.** An access token that would not unseal inside the refresh claim, and a 200 whose body is not a token response (HTML maintenance page, empty body, no `access_token`), were `GRANT_UNUSABLE` and wrote `REFRESH_FAILED`. Both are transient now; only `invalid_grant` / `invalid_client` / `unauthorized_client` is terminal.
* **R6 — escaping exceptions, and on-prem IdPs.** The callback caught only `ConnectionException` after claiming the state; an `IllegalArgumentException` from URL validation or an `IllegalStateException` from the store reached the browser as a 500 with the state consumed. Every `RuntimeException` after the claim is counted `exchange_failed`, logged at ERROR (class name only) and answered 303. `refreshAsClaimant` wraps the same as `TOKEN_ENDPOINT_UNAVAILABLE` (503). The token request no longer goes through the SSRF address block: the credential-endpoint allowlist is a stricter rule (an exact operator-listed origin), so an on-premises IdP on a private network is usable; scheme and host are still validated, nothing else gets the exemption.
* **R7 — Postgres lease vs the DB clock.** `claimRefresh` compared a JVM-written lease against `CURRENT_TIMESTAMP`; app/DB skew shortened the lease and let a second replica refresh mid-flight. The JVM instant is bound, as `PostgresOAuthStateStore` and the Mongo store already do.

**What the operator is told.**

* **R8** — with `authorization.enabled=false` every request is anonymous and its credential headers are dropped, so `NO_CALLER_CREDENTIAL` now says the deployment cannot accept one and names the fix, instead of claiming the request carried nothing.
* **R9** — the four OAuth/refresh meters were unprefixed and registered through a helper the `MetricsDashboardCoverageTest` regex cannot see; they are `eddi.connection.*` now, registered via `increment("eddi.…")`, charted in four new panels, and listed in `docs/metrics.md` and the `docs/connections.md` table. `UNSUPPORTED_PLACEMENT` was mapped to 400 but never thrown — `ApiCallExecutor` now throws it for a `${connection:…}` outside a header.
* **R10** — the MCP discovery warning named `PER_USER` for `CALLER_SUPPLIED` too and A2A logged nothing; `ConnectionResolver.bindingOf` names the actual binding for both, and routes a registry read failure through `countLookupFailure`. RFC 9728 discovery is not implemented, and the allowlist Javadoc and "Two allowlists" section no longer claim discovery endpoints.
* **R11** — a cached `STATIC`/`BASIC` document without `staticAuth` NPE'd, which the MCP failure classifier fed to the circuit breaker; both refuse with `INVALID_CONFIGURATION`, and BASIC with a null username refuses rather than sending `null:password`.
* **R12** — `VaultGrantChecker` skipped an unreadable connection at DEBUG (its secrets counted as granted); it is now a violation naming the connection. `${vars:}` is expanded through `GlobalVariableResolver` before the vault scan, on the connection hop and the general scan alike.
* **R13** — tests for `X-EDDI-Connection-Credential` parsing, the HITL resume bindings, the `lease_expired` branch (via a package-private await-timeout seam) and a failing `REFRESH_FAILED` write, which no longer replaces the provider's verdict with a raw store exception.

**Decision — `propagate` carries both bindings; `withIdentity` does not.** A wrapper that carries one of the two thread bindings and not the other is the drift R5 fixed, so the snapshot-current helper composes them. The explicit-identity helpers are used where the principal is deliberately different (HITL resume) or established later from stored memory (group members), so they stay single-purpose rather than silently overriding a principal the caller set.

**Decision — the credential-endpoint allowlist outranks the SSRF address block for the token endpoint only.** An exact origin an operator wrote down is a stronger statement than "not a private address"; the httpcalls path keeps `eddi.security.ssrf-protection` untouched.

**Not verifiable here:** `SafeHttpClientTest` binds a loopback server, which this sandbox refuses at `HttpServer.create` (pre-existing for the whole class); its three new no-redirect cases run in CI.

***

## 🧩 fix(connections): configuration, store and export findings from the connections review (2026-09-13)

**Repo:** EDDI (`fix/connections-review-findings`, merged from `wip/connections-config-findings`)

Thirteen findings from the connections code review, each its own commit with a regression test that fails without it. The runtime findings of the same review are on a sibling branch; the two meet in `docs/connections.md` and here.

**Write-boundary validation (`ConnectionConfiguration`):**

* **`valueTemplate` literal text is now checked** (C1). The old check inspected only the `${…}` segments, so `sk-live-abcdef${vault:unused}` saved while the docs said it was refused. Rule: every `${` must be a well-formed `${vault:…}`/`${vars:…}` reference (an unclosed brace or a key over 256 chars used to count as literal text), at least one reference, and each literal segment ≤ 32 chars with no run of ≥ 12 key characters. Scheme prefixes pass; a key does not.
* **The name has a grammar** (C2): `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, refused rather than trimmed. `ConnectionReference` stops at `/` and `}`, the credential header splits at the first space, so `acme/jira` resolved as tenant `acme` and a name with a space resolved for nobody.
* **`extraAuthParams` values are checked** (C7): no `${…}` reference (it would be resolved into a browser-visible URL), ≤ 512 chars, no credential-shaped prefix (`sk-`, `xox?-`, `gh?_`, `AKIA`, `eyJ`, `Bearer`). The key denylist gains the parameters EDDI composes itself (`redirect_uri`, `state`, `code_challenge`, …), normalised so `Redirect_uri` is caught.
* **`timeoutMs` is bounded 1..60000** at save time (C9); the token client's own ceiling still clamps at use. A non-loopback `http://` origin in `baseUrlAllowlist` stays accepted but is logged at WARN when saved and again by the startup guard.

**REST store (`RestConnectionStore`):**

* **Name uniqueness is no longer check-then-act** (C3). Creates of one `(tenant, name)` are serialised on a striped lock inside the JVM; after the write lands the store is asked again who holds the name (`IConnectionStore.idsOfName`, oldest-first). **Review fix:** the first version of this left the descriptor to `DocumentDescriptorFilter` *after* the method returned — outside the lock — so the lock guarded nothing: a second create on the same node took it the instant the first released it, scanned, found no descriptor yet, and both landed. `RestConnectionStore` now writes the descriptor itself inside the lock (the filter finds it and does nothing; `RestImportService.recordCreatedConnection` writes one only when missing). Our own id is expected in the post-write scan and filtered out; the rule stays "any other holder visible now wins" — ours is removed permanently, descriptor included, and the caller gets 409. Chosen over "oldest wins" because under asymmetric visibility the latter duplicates the name; the cost is that two replicas seeing each other both stand down and both callers retry. What remains is replication lag between nodes.
* **Duplicate goes through `validateForWrite`** (C4) — it skipped the deployment checks.
* **`CALLER_SUPPLIED` needs OIDC** (C5): `CallerIdentityContext` drops the credential header for an anonymous identity, so with `authorization.enabled=false` the connection saved and failed every call as `NO_CALLER_CREDENTIAL`. Now 400 at the write boundary and a stored-state report at boot, like `PER_USER`.
* **An `authType`/`binding` change with linked accounts is a 409** (C6), naming the count and the way out (`DELETE /connections/{name}/grant`, or delete the connection). Re-saving a `PER_USER` OAuth connection as `STATIC` left every user's refresh token at rest under a name the resolver never read again. `IConnectionGrantStore.countByConnection` added (Mongo, Postgres, in-memory double) — the only change under `connections/grants/`.
* **Editors may list and read connections** (C11): `eddi-editor` on the descriptor listing and the single read; every write stays admin-only. A document carries references only.

**Startup guard:** dev/test now require a bare origin and accept plain http on loopback only, and the scheme is compared case-insensitively in every profile (C8); a first-release `OAUTH2_AUTHORIZATION_CODE` + `SERVICE` document is reported with the fix (C12).

**Export/import (C10):** an agent archive now carries the connections its configs reference, as `connections/{connectionId}.connection.json` — document only, never a grant. Two defects made this necessary rather than nice: `AbstractBackupService` had no connection entry, and `SecretScrubber` redacted `${connection:jira}` in an `Authorization` header to `${vault:REDACTED}`, so the reference died before the archive was written. **Review fix:** the scrubber's exemption is for a value that *is* exactly one `${connection:…}` reference, not one that contains it — every outbound path refuses a mixed value (`ConnectionReference.requireSole`), so a "contains" exemption only kept `Bearer sk-… ${connection:jira}` legible. Import creates a connection only when the name is free — an existing one is never overwritten — through `RestConnectionStore.createConnection` (same validation, deployment checks and lock as REST); a refused document is skipped with its reason and counted in `X-Connections-Skipped`. The descriptor is written by the store inside its name lock; the import writes one by hand only when it is missing, as `createResourceDirect` does. Live sync still does not carry connections; said so under Limitations. `AGENTS.md` §5.5 lists the file and counts thirteen.

**Docs (C13):** `configuration-reference.md` no longer describes `credential-endpoint-allowlist` as where resolved credentials go; it bounds the client secret.

**Files:** `configs/connections/**`, `connections/ConnectionStartupGuard.java`, `connections/grants/{IConnectionGrantStore,MongoConnectionGrantStore,PostgresConnectionGrantStore}.java`, `backup/impl/{AbstractBackupService,RestExportService,RestImportService}.java`, `secrets/sanitize/SecretScrubber.java`, `docs/{connections,configuration-reference,import-export-an-agent,agent-sync-architecture}.md`, `AGENTS.md`, and the tests named in each commit.

**Not done / for the runtime branch:** the resolver still reads `timeoutMs` through the client's own clamp (fine, now documented); `SecretRedactionFilter` was not touched. A legacy document written before C1 could carry a literal in `valueTemplate` and is now readable by editors (C11) — re-saving it fails validation, which is the signal to fix it.

**Review pass — token URL syntax check ran after the URI was built.** R6 added `UrlValidationUtils.validateUrlSyntax` to `OAuthTokenClient.exchange`, but after `URI.create(tokenUrl)`, so a token URL the allowlist accepts once trimmed but that will not parse (trailing whitespace on a document written straight to the store) still surfaced as a raw `IllegalArgumentException` — not a `ConnectionException`, which on the service-grant mint path is exactly what R11 keeps out of the MCP circuit breaker. The check now runs first, its parsed URI is the one fetched, and a failure is `INVALID_CONFIGURATION` naming `oauth.tokenUrl`.

***

## 🧪 fix: the defects a full end-to-end run of 6.4.0 found (2026-09-13)

**Repo:** EDDI (`fix/e2e-test-findings`, from `origin/main` @ `d0832d9da`)

A full E2E pass over a running 6.4.0 exercised 342 REST operations, all 84 MCP tools, 28 single-agent variations and every group style (Anthropic `claude-sonnet-5` via `${vault:anthropic-key}`). It confirmed 15 bugs and \~30 usability defects. **S1 (a vault-resolved key persisted in an httpcall body) was retracted as a false positive**: the check had matched the `<REDACTED>` placeholder, and `RequestRedactor` already scrubs bodies. Everything else is fixed here with regression tests.

**Security**

* **S2 — a `scope: "secret"` input survived in derived forms.** `PropertySetterTask` scrubbed only exact copies, but the parser tokenizes input (`unknown(sk-live_abc)`), so the key stayed in `expressions:parsed`, `expressions:matches` and `intents`. Those are now dropped wholesale once the input is scrubbed. The audit ledger was worse: parser and rules entries, submitted before the property setter ran, carried the plaintext into an append-only signed ledger. New `TurnAuditBuffer` holds a turn's entries until the pipeline finishes (say and resume paths), then redacts the recorded input everywhere in the entry before submitting. `MemoryKeys.SECRET_INPUT_PLACEHOLDER` is the shared marker.
* **S3 — unknown `environment` silently targeted production.** `EnvironmentParamConverterProvider` makes every JAX-RS `Deployment.Environment` parameter strict: `staging` → 400 naming the valid values.
* **S4 — config-authored httpcalls reached the cloud metadata service** with SSRF protection off (the default). `UrlValidationUtils.rejectCloudMetadataTarget` now refuses `169.254.169.254`, `fd00:ec2::254`, `100.100.100.200`, `metadata.google.internal` and the link-local ranges — including hostnames resolving there — on httpcall, MCP and A2A paths **regardless of the setting**. With protection off the httpcalls client still follows redirects, so `HttpClientModule` wraps its redirect handler and refuses any hop onto the metadata service (on a worker thread — the check can resolve DNS). Private/loopback targets stay reachable (that is what opting out is for).
* **S5 — export rewrote `modelName: claude-sonnet-5` to `${vault:REDACTED}`** (the entropy heuristic); the imported agent failed every turn. Model identifier fields are structural for `SecretScrubber`.
* **B16 — an unresolvable `${vault:…}` was sent to the provider as the key.** `SecretResolver.requireResolved` fails closed, naming the parameter and reference (never a value), in `ChatModelRegistry`, `EmbeddingModelFactory` and `EmbeddingStoreFactory`.
* **W16** `TRACE`/`TRACK` → 405 (`HttpMethodGuard`). **W17** input over `eddi.conversations.max-input-chars` (default 200000) is refused before any paid call: 413 `input_too_large` on REST and SSE (checked before the stream opens, so it is a real status, not an `error` event), 400 on the OpenAI API, invalid params over A2A. **W15** plaintext credentials in LLM configs are warned about at save (not rejected — setup falls back to plaintext without a vault), including Hugging Face `accessToken` and Azure OpenAI `nonAzureApiKey`. **B12** a declared image/PDF whose bytes carry no signature is rejected.

**Engine / metrics**

* **B6 — every priced tool failed in production.** Gauge `eddi.tool.costs.total` and counter `eddi.tool.costs{tool}` both render as `eddi_tool_costs_total`; Prometheus refused the second. Gauge renamed **`eddi.tool.costs.accrued`** (dashboards, `docs/metrics.md` and alert examples updated); meter failures no longer fail cost accounting, and a cost-tracking failure no longer replaces a tool result. *Breaking for anyone querying the old gauge name.*
* **B7 — shared artifacts were invisible to other members.** All members run as one user, so under USER cache scope `listArtifacts()` served a stale "no artifacts". `ToolCacheService.isCacheable` excludes every `@Tool` of the artifact, group-task, dynamic-agent, memory and recall tool classes (derived reflectively). The discuss/continue/human-input responses now attach artifacts too.
* **B14** rule pauses kept `hitlPauseType: null` (`clearToolPauseState` cleared it); now `RULE`, and stored legacy pauses report `RULE` in the inbox. **W25** the audit `modelName` was the provider type.
* **A1** active conversations no longer require `agentVersion`. **W12** memory search splits the query into terms (`MemorySearchTerms`) — "dog name" finds `dog_name`, and `%`/`_` are no longer wildcards.

**Groups**

* **B8 — `create_sub_agent` never inherited the parent's key.** Inheritance read the parent over the REST loopback from the LLM tool thread, which has no request to forward credentials from; the failure was swallowed. It now reads the in-process stores, and a failed inheritance says why.
* **B9** `GroupMember` defaults a null `memberType` to AGENT — the ops-task-force template found zero bidders. **B15/W5/W6** new save-time errors: member without `agentId`, negative turns/retries/timeouts/caps/repeats, preset DEBATE/DEVIL\_ADVOCATE without their roles, nesting cycles; template instantiation rejects non-existent agents; RAG rejects unknown `embeddingProvider`. Every shipped template passes (asserted). **W1/W7** enabling artifacts/tasks/dynamic agents logs the `enableBuiltInTools` prerequisite.
* **W3** `LAST_SYNTHESIS` accepts `**Option A:**`, list markers, `.`/`)` separators, any case. **W4** CRITIQUE without `targetEachPeer` reviews all peers (`TEMPLATE_CRITIQUE_PANEL`) instead of an empty target. **W8** EXECUTE without PLAN materializes configured tasks.

**API consistency**

* **W18/W32** agents referencing a malformed or non-existent workflow → 400; deleting an agent undeploys its live versions. **B10** bad snippet name / missing patch op → 400 (were 500). **B11** `/actions` skips steps without a URI (NPE → 500 on every real workflow) and names `workflowId`. **B13** A2A cancel of a finished task → not cancelable. The task's state is recorded per task (`a2aTaskMapping:state`), not inferred from its conversation, which a completed turn leaves `READY` for the context's next task: `tasks/get` answers `completed`/`canceled`/`failed` from that record. **W19** MCP discover-tools reports a refused configuration as 400. **W20** tool costs resolve by slug. **W21** unmatched endpoint filters are reported. **W22** aliased extensions listed once. **W23** unknown ingestion id → 404. **W24** channel descriptors get their name (create/update descriptor-version lag). **W26** `list_agent_resources` maps v6 step types. `read_conversation`'s `returningFields` passes sections to the service and filters output keys (it returned an empty snapshot for its own example). **W13** snippets read their current version. **W14** template preview resolves `{vars.*}`. **W29** setup states the streaming backstop. `apply_agent_changes` reports a failed redeploy.

**Docs:** security (metadata block), configuration reference, metrics, dashboards, group conversations (roles, options, cadence semantics, async approve/human-input, cycles, critique, EXECUTE without PLAN, cache), hitl, langchain (`anchorFirstSteps` is token-window only), user memory search, MCP client notes (protocol-version warning, non-idempotent retries).

**Review follow-ups** (independent review of the whole change): the audit buffer now redacts every input form any entry recorded from *every* buffered entry — including task-failure entries, which carry no `userInput` but can quote the token, and entries built after the scrub; a resolved RULE pause no longer leaves `hitlPauseType: RULE` on a READY conversation (it logged a stale-state WARN on every later turn); `updateAgent` checks EDIT on the agent before the workflow-existence lookup (no existence oracle, and a workflow the caller cannot view is a 403 by intent); the OpenAI adapter maps the input cap to 400 `input_too_large` instead of 500; a PDF header anywhere in the first 1024 bytes counts as a PDF; `EmbeddingModelFactory` trims the provider as `RagConfiguration` does; `eddi.conversations.max-input-chars` is declared in `application.properties` and documented in the conversation table. A later workflow of a multi-workflow agent sees empty parser data on a turn whose input was vaulted — deliberate, noted in `PropertySetterTask`. `EnvironmentParamConverterProvider` and `HttpMethodGuard` are unit-tested only; an integration test through the Quarkus stack is still open.

**Not changed, by design:** `${eddivault:` is a supported legacy alias; GDPR export `complete:false`; sync rejecting loopback sources.

## ⬆️ chore(deps): update Quarkus to 3.39.3 and langchain4j to 1.20.0 / 1.20.0-beta30 (2026-09-13)

**Repo:** EDDI (`chore/deps-and-version-6-4-0`)

* Bump Quarkus platform from `3.39.2` to **`3.39.3`** in `pom.xml`.
* Bump `langchain4j.version` from `1.19.0` to **`1.20.0`** and `langchain4j-beta.version` from `1.19.0-beta29` to **`1.20.0-beta30`** in `pom.xml`. Both lines are updated together to maintain module version alignment.

Also checked Docker base image statuses across the repository:

* **Production image** (`src/main/docker/Dockerfile`): `registry.access.redhat.com/ubi10/openjdk-25-runtime:1.24` pinned at `@sha256:c49d36c03d0a9472935b9f318f709c4cf158afa2dc204099f1f463cfbf9d4626` is already current (remote registry matches digest; tag 1.25 and ubi11 do not exist).
* **Sidecar image** (`mcp-sidecar/Dockerfile`): `ghcr.io/sparfenyuk/mcp-proxy` pinned digest is current with latest.
* **Demo image** (`src/main/docker/Dockerfile.demo`): newer digests exist on Docker Hub for `maven:3.9-eclipse-temurin-25` and `eclipse-temurin:25-jre`.

**Files touched:**

* `pom.xml` — updated `quarkus.platform.version`, `langchain4j.version`, and `langchain4j-beta.version`
* `docs/changelog.md` — this entry

## ⚙️ fix(config): fifteen configuration defects, from scheduler units to a nine-megabyte orphan (2026-09-07)

**Repo:** EDDI (`fix/review-quickwins-config`)

Slice 2 of the code-review quick-win backlog (`planning/code-review-backlog/`). Fifteen findings whose common shape is a knob that does not do what it says.

**Two were live defects.** `@Scheduled(delay = N)` is measured in **minutes**, and two jobs were written as though it were seconds: the deployment check started ten minutes after boot rather than ten seconds, and the daily maintenance job — which undeploys superseded agent versions and ends idle conversations — first ran five hours in, so a pod restarted more often than that never ran it at all. Both now use `delayed = "10s"` / `"5m"`, which carries its unit, plus `ConcurrentExecution.SKIP`. Separately, the properties migration renamed the legacy collection into a backup even when entries had failed to migrate; because `collectionExists` is then false, that made the migration a permanent no-op, so a transient error on three of four hundred users stranded those users' long-term properties in the backup collection. The rename is now conditional on a clean run, and the loop is idempotent so the next boot retries.

**Two settings could not fire at all.** `quarkus.mcp-server.http.root-path` is not a key the MCP extension knows — the namespace is `quarkus.mcp.server.*` — so changing it moved nothing while the auth rule kept guarding the old path. And `ComplianceStartupChecks` read `quarkus.http.ssl.certificate.file`, singular; the real keys are plural, so the TLS warning fired for operators who had configured TLS correctly and — worse — following the banner's own advice silenced it while Quarkus ignored the key, leaving the check reporting satisfied on a plaintext listener.

**Three defaults disagreed with themselves.** `Conversation` held its own `DEFAULT_MAX_RECALL_ENTRIES = 1000` for the absent-config case while `UserMemoryConfig` defaults to 50, so adding a `userMemoryConfig` block for an unrelated reason cut long-term recall twentyfold in a diff that never mentions the field. `WorkspaceSettings` validated an operator's value in a lazily created bean, so a typo booted green and threw a 500 on the first guarded request — and on every request after it. `TaskForceEngine` ignored the designer's `inputTemplate` at all three of its phases, which is the whole TASK\_FORCE style, so the phase-template mechanism was inert end to end for that style.

**Four things were hard-coded that the neighbouring surface treats as configuration.** The A2A turn timeout, the Slack turn timeout and retry budget, and the LLM refusal heuristic — four English prefixes, so a German- or Japanese-language agent's `onRefusal` guardrail could never fire while the prefixes over-matched a legitimate "I cannot confirm that from the data provided". All are now configurable, defaulting to exactly the constants they replace. Slack also answers a timeout with a notice naming the limit rather than a generic error.

**And two were dead weight.** 9.3 MB of orphaned Manager build output — a complete second Vite build, 19 files that only imported each other — shipped in every jar and image. The immutable one-year cache header matched an un-hashed script loaded by the landing page every visitor hits first, so a fix to it would have gone unseen for up to a year with no revalidation request even sent.

**Decision — fail safe on a value that would disable a surface.** A zero or negative Slack timeout, or a retry budget below one, is ignored in favour of the shipped default. Honouring it would take the channel down on a typo, and an operator who meant to disable Slack has a better way to say so.

**Decision — de-anchor the metrics guard rather than exempt the meters.** Fourteen meters across Dream, connection resolution, summarization and guardrails dropped the `eddi` prefix, and `MetricsDashboardCoverageTest`'s regexes required it — so the guard that exists to make an unwatched meter impossible could not see them, which is how they came to be absent from the metrics reference on a green build. They are renamed, the dashboard follows, and the anchor is gone so the next unprefixed meter fails rather than hides. The same shape applies to `ConfigurationReferenceCoverageTest`: it treated a declaration in `application.properties` as proof the code reads a key, so a documented-but-unwired key passed the very assertion whose failure message describes that situation. Split in two, it immediately surfaced a real one — `eddi.gdpr.restriction-cache-ttl-seconds`, injected and undocumented.

**Files:** eleven production classes, one new (`SlackConfig`), `application.properties`, the metrics dashboard, six pages under `docs/`, and eight test classes — three new (`StaticAssetCachingTest`, `ScheduledDelayUnitsTest`, `WorkspaceSettingsTest`, `SlackConfigTest`).

**Next:** slice 3 — API consistency across the REST surface.

***

## 📘 docs: correct 23 false claims and pin them with a guard test (2026-09-07)

**Repo:** EDDI (`docs/review-quickwins-docs`)

The first slice of the code-review quick-win backlog (`planning/code-review-backlog/`). Twenty-three findings, all of them documentation that said something the code does not do. Markdown compiles to nothing, so none of them were visible to any check in the build and several had been wrong for more than one release.

**The ones that cost a reader real time.** `AGENTS.md` and `configuration-reference.md` said a `scope: "secret"` property degrades to plaintext without a vault key; it fails the whole turn closed. The README named `quarkus.mongodb.connection-string` as the connection knob — a real Quarkus key that only the extension's health check reads, so pointing it at another host reports the new host as UP while every read and write still goes to the old one. Both first-agent tutorials named the workflow field `packageextensions`, which the strict write boundary rejects with a 400, and documented a Facebook channel connector that does not exist. The README documented span attributes without the `eddi.` prefix the code emits, so a trace filter on `task.id` matches nothing and reads as "tracing is not emitting".

**The ones that hid a shipped feature.** `behavior-rules.md` presented eight of the twelve registered condition types as the complete list; `deploymentContext` was documented nowhere at all. `AGENTS.md`'s ZIP section listed seven of twelve backup file extensions, so the most common case — an agent with a regular dictionary — could not be built from the file that tells you how to build one, and its workflow step table omitted the templating step the same section calls mandatory. Six shipped LLM task fields were documented nowhere, including the rolling conversation summary; `scheduling.md` omitted `oneTimeAt` and `metadata`; `user-memory.md` omitted `dream.parameters`, without which every summarization step fails with a provider 401 while stale pruning keeps working.

**Three admin surfaces gained their first documentation:** tenant quotas, coordinator dead letters and template preview. New pages `docs/tenant-quotas.md` and `docs/coordinator-admin.md`, a section in `docs/output-templating.md`, all linked from `SUMMARY.md`.

**Five plans in `planning/` described shipped subsystems as unbuilt.** Two went further and instructed an agent to implement them task-by-task, with 107 unchecked boxes between them, against a spec whose class names all resolve to files that already exist. Those two now carry a status marker, the directive is gone, and the checkbox syntax is stripped rather than ticked — stripping does not assert that every sub-step shipped, which ticking would.

**Decision — guard the claims, not the wording.** `DocumentationAccuracyTest` asserts that the docs mention what the code declares: every condition `ID`, every `*_EXT` backup constant, the schedule fields the validator enforces, the LLM task fields the model declares. A new condition type is then a failing test naming the missing type, not a reference table that quietly goes stale. Fixed expected strings would have needed editing on every rename and would have guarded nothing.

Proven by mutation. Removing the `oneTimeAt` row, dropping the templating step, deleting a backup extension and restoring the wrong MongoDB property each fail with the missing name in the message. The test also caught three errors in this very commit's edits before it was run deliberately.

**Files:** `AGENTS.md`, `README.md`; under `docs/` — `SUMMARY.md`, `behavior-rules.md`, `configuration-reference.md`, `creating-your-first-agent/creating-your-first-agent.md`, `creating-your-first-agent/creating-your-first-agent-1.md`, `deployment-management-of-agents.md`, `docker.md`, `langchain.md`, `monitoring/monitoring-guide.md`, `output-templating.md`, `putting-it-all-together.md`, `rag.md`, `release-versioning.md`, `scheduling.md`, `user-memory.md`, plus the two new pages `coordinator-admin.md` and `tenant-quotas.md`; under `planning/` — `conversation-cancel-plan.md`, `hitl-tool-approval-plan.md`, `mcp-hitl-surface-plan.md`, `multimodal-attachments-completion-plan.md`, `observability-and-pipeline-plan.md`; and `src/test/java/ai/labs/eddi/docs/DocumentationAccuracyTest.java`.

**Next:** slices 2-6 of the same backlog — config keys, API consistency, and 63 test-quality findings.

***

## 🏷️ fix(ci): a release tag could execute on the runner (2026-09-07)

**Repo:** EDDI (`fix/review-quality-gates`)

`PRIMARY_TAG` is `${GITHUB_REF#refs/tags/}`, and the only check on it was a *prefix* comparison against the pom version. So `6.3.0-$(id)` passed — a legal git ref name, therefore pushable — and nine `run:` blocks spliced it in with `${{ }}`, which the runner substitutes into the script text *before* bash parses it. Two of those blocks hold the Docker Hub credentials and the Sigstore keyless identity. Pushing a tag needs write access, so this is not anonymous execution; it is tag-push rights becoming arbitrary commands in the job where the release secrets live.

The whole tag is now matched against `^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$` before the parity check, and every one of the nine sites takes the value through `env:` instead of interpolation. All 54 published tags since 4.8.0 match; the pattern carries no version literal, so the single-source-of-truth guard stays satisfied, and it agrees with the no-`v`-prefix rule the release trigger depends on.

Two relational tests pin it. One lifts the pattern out of the YAML, compiles it, and runs 6 accepted and 18 rejected tags through it, so it grades the regex's behaviour rather than its presence — shown by a second experiment that widened the pattern to `^[0-9].*$` and still failed. The other sweeps every workflow for `${{ }}` interpolation of the tag inside a `run:` block, so a new site cannot reappear.

`ReleaseVersionSourceTest` caught the first draft of the error message for quoting a literal version as an example, which would have gone stale on the next bump. The guard works.

***

## 🧷 fix(build): a gate test that passed with its guard deleted (2026-09-07)

**Repo:** EDDI (`fix/review-quality-gates`)

Four review comments, two of them the kind this whole review exists to catch.

**A gate test was green with the thing it guards removed.** `dependabotSkipBlock()` falls back to returning the rest of the workflow when the guard is absent, and that text still contains both `--json files` and `"$DOCKERFILE"` — so both assertions passed against a workflow with no de-duplication at all. Confirmed empirically before fixing: guard physically deleted, old assertions, `Tests run: 20, Failures: 0`. The test now tracks whether the loop found the guard and asserts it afterwards, and fails with the guard gone.

**`defaultTestDeadLetterPath()` still accepted the source tree.** It checked only `Path.isAbsolute()`, so a `java.io.tmpdir` pointing at the repository root or a child was allowed and the sink landed back in the tree — the artifact this branch removed, reachable through a JVM flag instead of a code change. It now also rejects a temp directory inside the project directory, naming the property and the value. Containment uses `Path.startsWith`, which matches whole name elements, so a sibling that merely shares a textual prefix is still accepted; that control case is asserted so a regression to string comparison fails.

**Dependabot PRs were selected with the wrong filter.** `gh pr list --author 'app/dependabot'` is the user filter and is not guaranteed to return App-authored PRs. An empty result is silent here — the skip never fires and the job raises a digest PR duplicating Dependabot's. Now `--app dependabot`.

**Unquoted redirections.** 83 of them, into `$GITHUB_STEP_SUMMARY`, `$GITHUB_OUTPUT`, `$GITHUB_ENV` and `$GITHUB_PATH` across three workflows, all quoted — and a new test sweeps every workflow so the count cannot creep back.

***

## 🪝 fix(githooks): fetch from the remote being pushed to (2026-09-07)

**Repo:** EDDI (`fix/review-quality-gates`)

The pre-push hook's shallow-clone recovery fetch named `origin` literally, while git passes the remote as `$1`. A push to any other remote fetched from the wrong one. The effect is narrow — the fetch is reached only after `merge-base --is-ancestor` has already failed — but it made the recovery path silently useless for anyone pushing to a fork or a second remote, which is exactly the contributor workflow `CONTRIBUTING.md` describes. The hook now captures the remote argument and uses it.

***

## 🔧 fix(build): make the base-image check ask which Dockerfile, declare the YAML dependency (2026-09-06)

**Repo:** EDDI (`fix/review-quality-gates`)

Eight review comments, four behavioural. All eight were proven in a single mutation run that reverted every behaviour at once and produced exactly eight named failures, one per comment, with no cross-talk.

**The base-image check skipped itself on the wrong pull request.** It treated the first open `dependabot/docker/*` branch as covering the production Dockerfile, but `dependabot.yml` declares three docker ecosystems (`/src/main/docker`, `/mcp-sidecar`, `/.clusterfuzzlite`) and all three push branches under that prefix. A sidecar or fuzzing bump therefore silenced the check for the image that actually ships. It now asks `gh pr view --json files` whether the PR touches the production Dockerfile before skipping.

**A dependency was reaching the classpath by accident.** `jackson-dataformat-yaml` was arriving only through `json-schema-validator`'s transitive tree, so an unrelated bump could have removed it and broken YAML parsing with no declaration to point at. It is now declared alongside the CSV and XML modules.

**The dead-letter path trusted `java.io.tmpdir`.** A relative or empty value resolved against the working directory, which put the audit dead-letter file inside the source tree. It is now rejected with an `IllegalStateException` naming the property. The matching test also no longer requires the *global* absence of `eddi-audit-deadletter.jsonl` — a stale file from an older checkout made it fail for the wrong reason — and instead snapshots the repository-root sink and asserts it is unchanged.

**Note for the merge order.** This branch arms the build gates: Checkstyle moves to `failOnViolation`, and the formatter from `format` to `validate`. It should merge **last**, after a pre-flight run of the armed gates against main with everything else already in, or it will turn green branches red on violations they currently get away with.

***

## 🧪 test(build): make the gate tests unable to pass a disarmed gate (2026-09-04)

**Repo:** EDDI (`fix/review-quality-gates`)

Follow-up on the same branch, from an independent review round and four GitHub Copilot comments. Every one was the same defect in a different place: a test that grades the build gates while itself being satisfiable by a disarmed gate.

* **The coverage-gate test graded only what survived.** It walked the JaCoCo limits and asserted a value per counter it found, so deleting the `BRANCH` limit — or emptying the `<limits>` block entirely — still passed. It now compares the whole limit map against `{INSTRUCTION=0.90, BRANCH=0.80}`, so a deleted, renamed or retuned limit fails.
* **The langchain4j pinning test let an unpinned artifact through**, because the condition began `version != null`. An artifact with no `<version>` falls back to a BOM or transitive version, which is exactly what the test exists to forbid. Reproduced first by stripping the version from a real dependency and watching the old test pass.
* **The version-duplication sweep named `redhat-certify.yml` as an offender and did not scan it.** Reintroducing the very `default:` this branch removed would have passed. The sweep now covers five files and a dedicated assertion rejects any `default:` on that workflow's `version` input — the check that still bites after `pom.xml` moves past the stale literal.
* **The CI `code` path filter omitted `README.md` and `AGENTS.md`**, so a PR touching only those skipped the tests that grade them. Rather than fix the pair by hand, a new test derives the requirement: it sweeps the test tree for root documents any test opens, parses the filter out of `ci.yml`, and fails if one is unlisted. It asserts the sweep found something, so it cannot go vacuous itself.

**Not changed, deliberately:** the same gap exists for `docs/**`, where three more tests grade documentation. `ci.yml` documents skipping the build for docs-only changes as intentional, so widening it is a policy decision rather than a review fix.

***

## 🚦 fix(build): make the style, coverage and image gates able to fail (2026-09-04)

**Repo:** EDDI (`fix/review-quality-gates`)

From the whole-repository code review. Several of this project's quality gates were wired so that they could not fail — which is the root cause the review named for why the other findings shipped at all.

* **Checkstyle ran with `failOnViolation=false`.** The import and file-size rules AGENTS.md calls mandatory could not fail anything, and are violated on `main` today.
* **The formatter's `format` goal rewrote tracked sources on every compile** instead of checking them, so a contributor's build silently edited files rather than reporting them.
* **The JaCoCo 90/80 gate graded `jacoco-merged.exec`** in runs where the integration tests never produced it, so `./mvnw verify` failed a clean, all-green tree at 89 %. It now carries `<skip>${skipITs}</skip>` and runs where the data actually exists.
* **Failsafe was pinned to Surefire's version property**, so the two could silently diverge.
* **The container integration tests built a hand-copied Dockerfile** that had already drifted from the production one, so what CI verified was not what ships. They now build the real image.
* **The project version was duplicated as a literal** in the OpenAPI info block and in `application.properties`; both now resolve from the build.

### Regression coverage

`BuildQualityGatesTest` and `ReleaseVersionSourceTest` assert the gates are armed — that Checkstyle fails on violation, that the coverage check is skip-aware rather than unconditionally disabled, and that no version literal is reintroduced. `EddiImageDockerfileTest` pins the integration image to the production Dockerfile.

Recorded honestly as unverifiable here: the image build itself and the two version assertions need Docker and MongoDB Dev Services, so they compile but have never executed locally. CI is the authority for those.

**Reviewer note.** This branch changes the local build contract: `./mvnw compile` now fails on unformatted or badly-imported sources instead of quietly rewriting them. AGENTS.md is updated to say so, because the previous wording described the old behaviour.

## 🔌 fix(install): close the PR #714 review — a busybox probe that read every port as free (2026-09-07)

**Repo:** EDDI (`fix/installer-mongodb-port-conflict`)

Four findings from the Copilot review of [#714](https://github.com/labsai/EDDI/pull/714): two inline, two the review filed as "suppressed comments" in its body (no thread, so nothing to answer in place). All four were real. Also merged `origin/main`, which the PR had drifted behind far enough to go `CONFLICTING` — and per the repo's own experience a conflicting PR never runs `ci.yml` at all, so the merge is what puts this branch back under CI.

**1. `port_in_use` had over-corrected into the opposite bug (`install.sh`).** The previous entry below fixed busybox lsof reading every port as *taken* by requiring `LISTEN` in the output. But the probe was an `elif` chain: on Alpine-class systems the lsof branch is entered, finds no `LISTEN` in busybox's file dump, and the `nc` / `/dev/tcp` branches are never reached — so every port now read as *free*, and the raw docker bind error came back. A missing marker is not evidence. The chain is now: `ss` short-circuits (its absence of a match really is proof); a **positive** lsof match is trusted, a negative one falls through to a connect probe that behaves identically on every implementation. Verified in a real `alpine:3.20` container with a live listener — busy port detected, free port still free — and mutation-checked by restoring the `elif` chain, which fails the busy case.

While there: the lsof output is captured instead of piped into `grep -q`. `grep -q` exits on first match and can SIGPIPE the producer, which `set -o pipefail` then reports as a failed pipeline even though the port *was* found.

**2. The PowerShell installer validated every port variable up front (`install.ps1`).** A stale `GRAFANA_PORT=abc` in the environment aborted a plain install that never starts Grafana, and `-Full` rejected a bad `-MongoPort` before switching the database to PostgreSQL. The Bash installer has always validated inside `resolve_published_port`, i.e. only for components that are actually enabled. The eager loop is gone; `Resolve-PublishedPort` now validates its own argument. `-MongoPort` also gains a `$MongoPortRequested` capture, matching the six overlay ports and keeping the resolved value from overwriting the request.

**3 + 4. The Compose project name was derived two ways that Compose does not use (both scripts).** `compose_project_name` / `Get-ComposeProjectName` decide whether a listener is *our* container (reuse the port) or a foreign one (remap, or fail an explicit request). Both ignored `COMPOSE_PROJECT_NAME`, and both assumed the project directory is `EDDI_DIR` — but Compose derives it from the directory of the **first** `-f` file, which under `--local` / `-Local` is the repo checkout. Confirmed against docker compose 29.7.2: first `-f` in `RepoCheckout/` yields project `repocheckout`, first `-f` in `.eddi/` yields `eddi`, and `COMPOSE_PROJECT_NAME` overrides both. Both functions now follow the same three rules.

**Design decision.** Detection was fixed by falling through rather than by sniffing for busybox (`lsof -v`, `--help`, applet name). Busybox ignores its argv here, so every sniff is a guess about which not-real-lsof this is; "trust a positive, verify a negative" needs no such guess and is correct for any implementation, present or future.

**Verification.** `alpine:3.20` behavioural harness plus its mutation check; `bash:3.2.57` harness for `compose_project_name` (7 cases, `set -u` safe); shellcheck at CI's exact invocation (`--severity=warning --shell=bash`) clean; `install.ps1` parses and passes a 9-case harness under **both** pwsh 7.6.5 and Windows PowerShell 5.1; five end-to-end `-WhatIf` runs of the real installer covering the stale-variable case, its negative control, `-Full` with a bad `-MongoPort`, and explicit `-MongoPort` accepted and rejected. PSScriptAnalyzer: **7 findings, 0 Error — now genuinely identical to `main`'s baseline.** The PR description had claimed that already and it was not true: the branch was adding 7 `PSAvoidUsingPositionalParameters` warnings, one per `Resolve-PublishedPort` call site, for 14. Those call sites now pass named parameters.

**Codacy is still red and it is not those seven.** It reports `7 new issues (0 max.)` — the same count before the branch's fixes, after them, and after the positional-parameter change, so the matching number was a coincidence. Ruled out from outside: PSScriptAnalyzer is byte-identical to `main` at every severity including `Information`, and shellcheck with no severity filter at all (Codacy's default, not CI's `--severity=warning`) reports a single `SC2129` note on a pre-existing line. The check publishes no annotations, an empty summary and no text — its title even reads "of at least severity" with the severity name missing — so the issue list exists only inside the Codacy dashboard, which needs an account to read. **Open:** someone with Codacy access has to open [the PR page](https://app.codacy.com/gh/labsai/EDDI/pull-requests/714) and say what the seven are.

**Superseded on merge.** `main` (#736) deleted `docker-compose.postgres.yml` outright — it was a drifted near-duplicate that could not work as the overlay the README documented, and its role is now `docker-compose.postgres-only.yml`. This branch's two fixes to that file (the doubled `7070:7070` publish, and moving `postgres` to a loopback binding) are moot: the replacement already interpolates `${EDDI_PORT:-7070}` once and publishes no database port at all. The merge takes the deletion, and the `POSTGRES_PORT` line this branch added to `.env.example` is removed with it — it pointed at a file that no longer exists.

**Second review round (CodeRabbit).** Two more findings, both valid.

*The `ss` branch had the same SIGPIPE hazard I had just fixed one branch below.* Fixing it for `lsof` and leaving `ss` piped into `grep -q` was inconsistent, and the consequence is the worse direction: `grep -q` exits on its first match, ss takes SIGPIPE while still writing, `set -o pipefail` makes the pipeline non-zero, and a port that **is** in use reads as free — straight into the raw docker bind error this whole branch exists to prevent. It needs a host with enough listening sockets to overflow the 64 KiB pipe buffer, which is exactly the kind of machine that has a port conflict. Now captured before matching, like the lsof branch. Proved with a stub `ss` that emits the matching row first and then 330 KB of filler: the piped form reports the busy port as free, the captured form does not. Re-checked against a real `ss` (debian + iproute2) with a live listener, including the anchor case where port 99 must not match a listener on 9999.

*Both installers documented the opposite of what a pinned port does.* `install.sh --help` closed its list of port variables with "kept when free, moved to the next free port when something holds it", and `install.ps1`'s `.NOTES` said the same — but every entry in that list is an **explicit** request, and an explicit request that is busy fails by design rather than moving. A user who pinned `KEYCLOAK_PORT` was promised a silent remap and got an abort. Both texts now separate the two paths, as does the `-MongoPort` parameter help.

**Files:** `install.sh`, `install.ps1`, `docs/changelog.md`

***

## 🔐 fix(deploy): no credential in the auth component has a default any more (2026-09-07)

**Repo:** EDDI (`fix/review-deploy`)

Three review comments on the development auth overlay, all of them fair.

**The shipped realm allowed cleartext for everything.** `sslRequired: "none"` let Keycloak serve the login form, the authorization code and the token endpoint over plain HTTP to any caller. It is now `"external"` in all three realm copies — Keycloak exempts local addresses, so every documented path still works: the quick start uses `kubectl port-forward` (arrives as `127.0.0.1`), compose sees the bridge gateway, and EDDI's backchannel runs pod-to-pod on RFC 1918. The one case where `external` would bite, a pod CIDR outside RFC 1918 such as `100.64.0.0/10`, is documented with the `X-Forwarded-Proto` requirement rather than left to be discovered.

**The secret generator printed a key it had not installed.** Under any `-WhatIf` run the PowerShell script reached the key box, because the report block sat outside the `ShouldProcess` gate — telling an operator a master key was installed when nothing was, and printing a secret that exists nowhere. It now tracks whether the create actually happened. The bash twin has no dry-run mode and so no equivalent path; that invariant is now written down next to its key box, and the new test sweeps both scripts so the two cannot drift.

**The component shipped a guessable full administrator.** `admin/admin` plus a privileged `eddi/eddi` account, on a workload fronted by a ClusterIP every pod in the namespace can reach. The "development only" framing is real but does not cover that, and `"temporary": true` is not a mitigation — Keycloak 26 drops the required action on realm import, which this repo had already recorded. `KC_BOOTSTRAP_ADMIN_PASSWORD` now comes from an operator-created Secret with no default, so the component fails closed with `secret "keycloak-admin" not found`; and the privileged `eddi` fixture ships with no credential at all, keeping its roles so recovery is one console action rather than recreating a user, two roles and a group. The unprivileged fixtures are untouched, so the demo login still works.

Each change is pinned by a relational assertion rather than a literal: the realm must require TLS for external clients, a generator may print a key only if it issued the create, no shipped manifest may carry a usable default privileged password, and the Secret name in the YAML must appear in the `kubectl create secret` command the docs give.

***

## 🧭 fix(ci): build on every operator document the manifest suite asserts about (2026-09-07)

**Repo:** EDDI (`fix/review-deploy`)

A review comment asked that no operator-facing document this suite pins can be edited without `build-and-test` running. The `operator_docs` filter covered `docs/kubernetes.md` and `README.md`, but `docsNameTheRealClientId` also reads `docs/security.md` and asserts it names `eddi-frontend` rather than the stale `eddi-manager` — and that file matched neither filter. This branch changed `docs/security.md` itself, so a revert of it would have sailed past the only test that guards it.

Closed at the mechanism rather than in prose: the filter now covers what the suite actually reads.

***

## ☸️ fix(deploy): stop the secret generator deleting on the normal path, pin config to its pods (2026-09-06)

**Repo:** EDDI (`fix/review-deploy`)

Fifth review round on this branch. Five comments, all behavioural, all fixed and each proven by reverting the change and watching a named test fail.

**The secret generator deleted before it created, on every run.** `kubectl delete secret eddi-secrets --ignore-not-found` ran unconditionally, so the window between delete and create existed even when the operator had asked for nothing destructive. A pod starting in that window came up without its vault key. The delete now lives inside the `--force` branch in both `create-secrets.sh` and `create-secrets.ps1`; the normal path relies on `kubectl create` refusing with `AlreadyExists` and reports that nothing was changed.

**The test suite had pinned the race as a requirement.** Two normal-path tests asserted the scripts "must keep the delete-then-create it does once past the guard" — so fixing the scripts alone would have turned them red, and leaving them would have blocked the fix forever. This is the failure mode this review keeps finding: a test that guards the bug rather than the contract. Both now assert the delete is reachable only under `--force`.

**A config change did not restart the pods that read it.** The Deployment pod template gained `checksum/config` and `checksum/secret` annotations hashing the rendered `configmap.yaml` and `secret.yaml`, so `helm upgrade` rolls pods when their configuration actually changed. Without it `envFrom` kept serving the old values until something unrelated caused a restart — the same trap the Keycloak upgrade note now documents, with `kubectl rollout restart deployment/eddi`.

**Files:** `k8s/create-secrets.sh`, `k8s/create-secrets.ps1`, `helm/eddi/templates/deployment.yaml`, `k8s/overlays/auth/kustomization.yaml`, `docs/kubernetes.md`, `src/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.java`

***

## 🔎 test(deploy): assert manifest relationships, not the presence of strings (2026-09-04)

**Repo:** EDDI (`fix/review-deploy`)

Follow-up on the same branch, from three independent review rounds over the deployment fixes.

The manifest suite was asserting that literals exist. A test for the Keycloak security context checked only that `runAsNonRoot: true` and `runAsUser: 1000` appear somewhere in the file after comment-stripping, which a commented-out or wrongly-nested block satisfies. Assertions now resolve the YAML and check the value on the container that actually runs.

The CI path-filter test asserted that a forced-true exists for tagged releases, but that contract is **positional** — the `echo "<filter>=true"` has to sit inside the `if [[ "$GITHUB_REF" == refs/tags/* ]]` branch to mean anything. It now checks placement.

The secret-generator ordering test was strengthened to assert the guard precedes the destructive delete *and* that an `exit 1` sits between them, so a guard that only warns fails.

Two justifications were withdrawn after the reviewer disproved them: the secret scripts were filed as needing a live cluster, when their fail-closed classification is plain text parsing; and the Helm chart version claim was pinned to the break it documents by asserting `manager`, `monitoring` and `namespace` really are absent from `values.yaml`, so the major bump cannot become a different lie.

**Coverage note.** Diff coverage of Java changed lines is 100%, but that figure is the intersection of the tool with an almost-empty `src/main` diff — this branch is 38 non-Java files. The manifests are covered by the structural suite instead, which is stated plainly rather than presented as a coverage result.

***

## ☸️ fix(deploy): repair Keycloak realm substitution and the secret generator, add a manifest regression suite (2026-09-04)

**Repo:** EDDI (`fix/review-deploy`)

From the whole-repository code review. The shipped Kubernetes and Helm assets failed *silently at deploy time* rather than loudly at render time.

**Applying the documented quick start destroyed the vault master key.** `k8s/base` shipped `eddi-secret.yaml` as a live resource, so every `kubectl apply -k` re-applied the placeholder committed to this repository over whatever key was installed. On a first install the operator silently ran with a published key; on any later apply the real key was overwritten and everything sealed with it became undecryptable. The manifest is now `eddi-secret.yaml.example` and is not applied; `create-secrets.sh` refuses to clobber an existing secret unless asked.

**Both shipped `k8s/examples/` kustomizations failed to build at all** — verified by running `kubectl kustomize`, which exits non-zero because an overlay reaches a file outside its own root. Composing overlays the way their own headers instruct also silently discarded their ConfigMap patches, so a NATS deployment came up still set to in-memory messaging.

**Keycloak never became ready and never had a realm.** The probes targeted a port serving no health endpoint, so the pod stayed NotReady forever and its Service got no endpoints; and the deployment never imported the realm, so OIDC discovery resolved against a realm that did not exist. Keycloak also ran `start-dev` with no persistent volume, so every restart wiped realms, clients and users — it is now a StatefulSet with a volume.

**The Helm realm substitution matched nothing.** The chart replaced the literal `https://eddi.example.com`, but both shipped realm copies advertised a *different* placeholder host, so `helm` exited 0 and login died with `Invalid parameter: redirect_uri`.

### Regression coverage

Deployment assets were the one area with almost no automated coverage, which is why these shipped. This branch adds `DeploymentManifestsTest` — a structural suite that asserts *relationships* rather than presence: that the realm placeholder the chart substitutes is the one the realm files actually carry; that the token issuer is derived from the same public URL on both the Helm and Kustomize paths; that the realm volume resolves to a ConfigMap the overlay genuinely generates; and that the secret generator checks before it deletes.

A new CI `manifest-lint` job renders every kustomization and lints the chart, so a manifest that does not build fails the pipeline instead of a deployment. It is deliberately a PR gate and not a release blocker, for the same reason `shell-lint` is: a manifest typo should not hold up a security patch.

The six tests were proven by mutating all six inputs at once and confirming exactly six failures with no collateral.

## 🧾 fix(gdpr): name the conversations an export lost, and stop a cache miss disowning a delete (2026-09-07)

**Repo:** EDDI (`fix/review-audit-gdpr`)

Six review comments, four of which were raised only inside collapsed review-body sections that never became inline threads, so nobody had opened them.

**A cache failure disowned a delete that had succeeded.** In `deleteUserData`, `forgetRestriction` sat between the memory delete and the count assignment inside one `try`, so an eviction failure reported zero memories erased and named `userMemories` as the failed step — for a delete that had already committed. The count is now assigned immediately after the delete, and cache eviction is its own step recording `restrictionCache`.

**The export silently dropped conversations it could not read.** A snapshot that threw, or came back null between the id lookup and the read, was logged and skipped while `complete()` knew nothing about it — the same reports-success-while-incomplete failure this branch exists to fix, one method away. Failed ids are now collected, surfaced on `UserDataExport` and the MCP payload, counted in the `GDPR_EXPORT` audit entry, and folded into `complete()`.

**Two documents described an API that no longer exists.** The erasure example in `docs/gdpr-compliance.md` predated six counters, `failedSteps` and `complete`, and the export note still said 206; both are corrected, and the operator is told to check `complete` before filing an Art. 17 request as fulfilled. `docs/audit-ledger.md` listed the wrong indexes and claimed the collection takes inserts only — `pseudonymizeByUserId` issues an `updateMany` under Art. 17(3)(e).

**Two tests that could pass while broken.** `McpGdprToolsTest` now derives its expected key set from `GdprDeletionResult`'s record components, so a component added to REST cannot silently miss MCP; and the pseudonym assertion checks the full `pseudonymFor` value rather than just the prefix, which a pseudonym computed over the wrong input would have satisfied.

***

## 🕵️ fix(gdpr): log the pseudonym, not the identifier the erasure just removed (2026-09-07)

**Repo:** EDDI (`fix/review-audit-gdpr`)

A reviewer pushed further than the previous round did, and was right to. Sanitising the `userId` in the GDPR delete-all log stopped a caller forging log records, but it left the identifier itself sitting in the log — on the one code path whose entire purpose is to remove that identifier. Logs outlive the database and travel further than it does, so an erasure that writes the user's id into them has not finished the job (CWE-532).

Both user-memory stores now log `AuditHmac.pseudonymFor(userId)`: the same deterministic SHA-256 the erasure cascade already substitutes into the audit ledger. An operator can still correlate the log line with the ledger entry, and neither holds the identifier. The pseudonym is hex, so it also cannot carry a record boundary — the injection fix is subsumed rather than discarded.

`PostgresUserMemoryStore.deleteAllForUser` gained the null guard `MongoUserMemoryStore` has always had. Erasing "all entries for user null" is not a request anyone means, and the pseudonym refuses a null identifier rather than hashing one.

The regression test asserts the stronger contract: the raw id must not appear in the captured log at all, and the pseudonym prefix must. Reverting the change fails it with the offending line quoted.

***

## 📤 fix(gdpr): stop calling an export complete while four data categories are missing (2026-09-07)

**Repo:** EDDI (`fix/review-audit-gdpr`)

Six review comments, plus the log-injection round.

**The portability export overstated itself.** `conversationsTruncated` was the only completeness signal, but the endpoint's own interface documents that every export omits group transcripts, shared artifacts, schedules and HITL journal entries. A user whose data lived only in those categories received a 200 the API described as complete — a GDPR Art. 20 answer that is not true. `UserDataExport` now derives `complete` and `omittedCategories`, both mirrored into the MCP payload so the two surfaces agree, and the response carries the distinction in the status line as well as the body.

**206 was the wrong status and is now 207.** 206 Partial Content is a *range* status and means something specific about byte ranges. 207 Multi-Status says "composite operation, read the body", which is what this is, and it is already what the sibling erasure endpoint uses. Because the four categories are always omitted today, the export always answers 207; the 200 branch returns when those exporters land. The four missing exporters are deliberately not implemented here.

**A warning claimed a write that had not happened.** `warnIfCostBudgetIsUnenforceable` ran before `setQuota`, so a failing store still logged that the limit was stored and would apply. Moved after the write returns.

**The SSE and synchronous quota surfaces disagreed.** A `QuotaAccountingUnavailableException` with no message produced `"message":""` on the streaming path while the synchronous mapper produced `Quota accounting unavailable`. Both now use one shared fallback.

**Two stores gave a different refusal reason than the gates around them.** The Mongo and PostgreSQL tenant-quota stores said "Cost accounting failed" where every sibling gate says "Quota accounting unavailable — denying request for safety". Aligned.

**A documentation contradiction, half real.** `AuditLedgerService` has two overflow paths, and only one dead-letters. `submit()` reserves its slot before a sequence is assigned, so a rejected submission is simply counted and dropped; `offerBounded()` on the retry paths dead-letters, because those entries already hold a chain position. The Failure Handling prose described the second and generalised it to both. It is now a table naming each path and its recoverability, with the consequence stated: a dropped submission is unrecoverable *and* leaves the chain `INTACT`, so `eddi_audit_entries_dropped_total` is the only signal and a clean `/auditstore/verify` is not proof of completeness.

**One comment was wrong and is recorded as such.** A reviewer said a fixture stubbed the wrong descriptor read. The call graph is the other way round — `describe()` reaches `readDescriptor`, not `readCurrentDescriptor`, and the latter appears nowhere in the service. What misled the reviewer was the fixture's own comment, which claimed the opposite; the comment was corrected and the stub left alone. Following the suggestion would have stopped the test reaching the production guard at all.

**Log injection.** The GDPR delete-all logs on both user-memory stores wrote the caller-supplied `userId` raw. Both now sanitize it — on the erasure path, which is exactly where a log has to be trustworthy.

***

## 🔐 fix(gdpr): stop caching "not restricted" by default; make foreign audit sequences visible (2026-09-06)

**Repo:** EDDI (`fix/review-audit-gdpr`)

Two reviewer comments had been reported closed but were not. Both are now closed properly, and the second one is closed by admitting what is not fixed rather than by claiming it is.

**The Art. 18 restriction cache failed open by default.** A previous pass added `eddi.gdpr.restriction-cache-ttl-seconds` and made `0` disable the cache, but left the default at 30 seconds — so on a stock multi-replica deployment a node that had cached "not restricted" kept processing for up to 30 seconds after another node applied the restriction. That is exactly the window the comment described, and an opt-in switch does not close it. The default is now `0`: every turn reads the store, and caching becomes an explicit single-node or conversation-affinity optimisation, documented as such. The alternative considered was a positive-only cache, which was rejected because it would only ever help restricted users — the rare case — while still delaying an *un*restriction across the cluster.

**Audit sequence allocation is still not cluster-safe, and now says so.** The reviewer asked for a storage-level atomic reservation plus a unique `(conversationId, sequence)` constraint and retry. That is deferred: it moves a store round trip from once per conversation to once per entry on the pipeline thread, and it is a schema change on two backends. The constraint must not land on its own either — for an audit ledger a rejected insert silently drops a record, which is worse than a duplicate the verifier can detect. What was genuinely missing and is cheap is *detection*: `flush()` now re-reads the store's maximum for each conversation it wrote and, if the store already holds a position at or beyond this node's next free one, increments `eddi_audit_sequence_collisions_total`, logs a WARN naming the conversation, and advances its counter past the foreign rows. An operator running multi-replica without affinity sees it in metrics instead of discovering it as a `BROKEN` verdict at verify time. The detector has no false positives and is documented as partial: two nodes handing out an identical range leave a maximum consistent with both counters and are still only caught at verify time.

`IAuditStore` and `docs/audit-ledger.md` now lead with the limitation, the deferred fix, and why it is deferred, so the row can no longer be read as "already correct".

***

## 📒 fix(audit,gdpr,tenancy): repair ledger persistence, erasure reporting and quota windows (2026-09-04)

**Repo:** EDDI (`fix/review-audit-gdpr`)

From the whole-repository code review. The subsystem a regulated buyer is actually paying for did not work on a supported backend.

**The PostgreSQL audit backend could not store entries EDDI legitimately produces.** `conversation_id`, `AGENT_ID` and `AGENT_VERSION` were `NOT NULL`, and the insert unboxed a nullable `agentVersion` with `setInt`, throwing an NPE that escaped `appendBatch`'s catch entirely. Six shipped call sites pass a literal null — ordinary HITL approvals and group turns among them — so a single compliance or oversight entry **discarded roughly three flush windows of unrelated conversations' audit data**, while the compliance event itself was never recorded. On the read side `getInt` mapped a stored SQL NULL to `0`, which the HMAC canonical form renders differently, so any such row would have verified as tampered.

The ledger is append-only evidence, not logs. Losing other conversations' entries because one entry is malformed is the worst possible failure mode for it.

**GDPR erasure returned 200 and "complete" even when steps failed**, and reported memories as deleted before deleting them. It now reports per-step outcomes and answers 207 when the cascade is partial. The MCP admin surface for the same operation hardcoded `"status": "completed"` and is now driven by the real result, so an operator — or an agent calling the tool — is no longer told a lossy erasure succeeded.

**Quota windows** were compared against a stale in-memory view, and the bootstrap silently ignored later configuration changes; both now warn when stored and configured values diverge instead of quietly preferring one.

## ⏸️ fix(schedule): a human-approval pause is a skip, not a failure (2026-09-07)

**Repo:** EDDI (`fix/review-schedules`)

Two review comments that were raised but never posted as inline threads, so nobody had opened them.

**A scheduled turn that paused for human approval was dead-lettered.** `ConversationService.say` throws `ConversationAwaitingApprovalException` *before* the response handler is wired, so the `SKIPPED` branch this branch added for exactly that case could never run. The executor's broad catch recorded the fire `FAILED`, incremented `failCount`, and eventually dead-lettered a conversation whose only crime was waiting for a human. A catch for that exception now sits ahead of the broad one and sets `FireStatus.SKIPPED`.

**The Mongo existence probe claimed a primary read it never requested.** `logFire`'s compensating re-read asked for no read preference, so it inherited `ReadPreference.nearest()` from the single `MongoDatabase` producer and could be answered by a lagging secondary still holding the schedule that had just been deleted — while its own Javadoc said it "reads from the primary". The probe now asks for the primary explicitly, and the Javadoc describes what the code requests rather than what a deployment might happen to be configured as.

***

## ⏰ fix(schedule): a fire log can no longer outlive the schedule it belongs to (2026-09-06)

**Repo:** EDDI (`fix/review-schedules`)

Review round on this branch: 17 comments. Six were genuinely open and are fixed; the substantive one took two attempts, because the first was a mitigation described as a fix.

**Erasure could report success over a log row it had not removed.** `deleteWithCascade` deleted logs and schedules in a transaction and then swept again after commit, which catches every log written before the sweep — but a fire already in flight can commit its log afterwards, so a GDPR erasure still reported success over a row carrying the erased user's `conversationId`. The window is now closed at the *write* side rather than by widening the sweep.

On PostgreSQL `logFire` issues a guarded insert — `INSERT … SELECT … WHERE EXISTS (SELECT 1 FROM eddi_schedules WHERE id = ?)` — so the subquery is evaluated under the same snapshot that writes the row and a log for a deleted schedule cannot be committed at all. Zero rows is the correct outcome, logged at DEBUG, never thrown: a benign race must not surface on the fire path. A foreign key with `ON DELETE CASCADE` was the other candidate and was rejected — existing deployments already hold orphaned fire logs, which is the bug, so `ADD CONSTRAINT` would fail on exactly the installs that need it.

MongoDB has no conditional insert, and a pre-check only moves the race. So it inserts, re-reads the schedule from the primary, and deletes the log it just wrote if the schedule has gone. Against the cascade's three steps there is no interleaving where the log survives its schedule: either the schedule delete precedes the re-read and the compensation fires, or it does not and the cascade's own delete or the post-commit sweep catches the document.

Both sweeps are kept, re-framed as belt-and-braces for logs written by a replica that had not yet observed the delete. The one operator-visible consequence — a schedule deleted mid-fire may lose that attempt's log — is documented in `docs/scheduling.md` as deliberate.

**Outcome writes are fenced by the claim's fire id.** `markCompleted`/`markFailed`/`markSkipped` and `markDeadLettered` now take the expected fire id, so a fire that exceeded its lease cannot overwrite the outcome of the fire that reclaimed the row.

**Three CodeQL log-injection sites** in `PostgresScheduleStore` (`scheduleId`, `agentId` and a HITL timeout schedule name, all caller-supplied) now go through `LogSanitizer.sanitize`, matching what `MongoScheduleStore` already did.

**Redirects no longer rewrite every method to GET.** `SafeHttpClient` splits the rule per status: 307/308 preserve method and body, 303 rewrites to GET, and 301/302 rewrite only POST — so PUT, PATCH and DELETE keep their method, body and `Content-Type`.

**The minimum-interval check no longer depends on when it runs.** `CronParser` derived the gap by walking fires from `Instant.now()`, so the same expression could pass validation on one day and fail on another. It is now computed from the parsed fields: the tightest pair within a firing day, and the tightest gap across days scanned over a full 28-year Gregorian cycle.

**Correction.** An earlier entry on this branch described scheduling as "exactly-once". Delivery is at-least-once — `IScheduleStore`, `docs/scheduling.md` and `docs/hitl.md` all say so — and that line has been corrected in place.

***

***

## ⏱️ fix(schedule): close the review round and pin the guards by mutation (2026-09-04)

**Repo:** EDDI (`fix/review-schedules`)

Follow-up on the same branch, from three independent review rounds plus a diff-coverage pass.

**Two CI failures this branch caused are fixed.** `ImportStyleTest` was red because the branch introduced two inline fully-qualified names — the exact convention that test enforces — in `RestScheduleStoreTest` and `MongoScheduleStoreTest`. And the vendored fuzz sources drifted because a Javadoc reformat of `PathNavigator` diverged from the copy `.clusterfuzzlite` vendors; the cosmetic edit is reverted rather than re-syncing the vendored file, keeping the diff to what the findings required.

**Tests that could not fail were replaced.** Five were proven vacuous by mutation, not by inspection. Two `WordSplitter` cases never reached the bounds guard they claimed to pin — one used an input whose index made the new `i > 0 &&` term unreachable. A `MongoScheduleStore` test asserted `!rendered.contains("triggerType=CRON")` on a `Bson.toString()` where that string can never appear, so it was unconditionally true; it now encodes through the real codec registry and asserts BSON null for an absent trigger type and the value for a present one, catching both an invented default and a hardcoded null.

Two further claims were **disputed with evidence and left alone**: their "changed" line was a rename from an inline FQN to an import, mandated by AGENTS.md 4.7. No test can fail on the revert of a rename, so the correct remedy is to drop the line from the coverage claim, not the test from the suite — and both were shown to kill real mutants first.

**Diff coverage** of changed lines: 94.4% to 99.2% line, 89.3% to 98.2% branch.

***

## ⏰ fix(schedule): correct fire bookkeeping, persistence and manual-fire claiming (2026-09-04)

**Repo:** EDDI (`fix/review-schedules`)

From the whole-repository code review. Scheduled fires were reporting success they had not earned, and losing state they had been given.

**PostgreSQL lost the payload entirely.** `eddi_schedules` had no column for `message` — the text a CRON schedule sends to the agent, which `RestScheduleStore` makes mandatory on save — nor for `time_zone`, `one_time_at`, `environment`, `agent_version`, `created_by` or `persistent_conversation_id`. The value was written, silently dropped, read back null, and the scheduled turn ran with **null input**. Scheduling is enabled by default and PostgreSQL is a documented, supported backend. The columns are added with `ADD COLUMN IF NOT EXISTS` statements so existing databases upgrade in place, and the dropped `persistent_conversation_id` was separately re-opening the CAS claim on every heartbeat fire, breaking the single-owner CAS claim that keeps a fire from running twice. (The delivery contract is at-least-once, not exactly-once — `IScheduleStore`, `docs/scheduling.md` and `docs/hitl.md` all say so. An earlier draft of this entry claimed otherwise.)

**Failures were recorded as successes.** The executor read its outcome from a latch that counts down on the failure branch too, so an error inside the pipeline looked like a green fire: retry, backoff and dead-lettering never engaged, and `docs/scheduling.md` documents a state machine that could not be reached.

**Persistent fires un-claimed themselves mid-flight.** The strategy wrote the pre-claim schedule back with `replaceOne`, so the poller re-claimed and re-fired a schedule that was still running, routing both turns into the *same* persistent conversation — two interleaved turns, two cost charges, one memory.

**Heartbeats drifted.** The next fire re-anchored on the moment a turn *finished* rather than when it was *due*, so a 40-second turn on a 60-second cadence actually fired every 100 seconds.

**A manual "fire now" took no cluster claim at all**, so it could run concurrently with the poller's own fire of the same schedule.

Also: `PUT /schedulestore/schedules/{id}` silently erased `createdAt`, `createdBy`, `lastFired` and the claim state on MongoDB (PostgreSQL preserved them — a parity gap in the same feature), and `CronDescriber` rejected day-of-week `7`, which `CronParser.validate` accepts, so a valid stored schedule 400'd on read.

### Regression coverage

Every behavioural change is pinned by a test proven to fail with its fix reverted. Four tests that the auditor found could pass with the fix removed were rewritten to assert the corrected value precisely rather than a property the buggy code also satisfied — one had asserted only that the next fire time lies in the future, which the drifting formula did too.

Three of this repository's own guard tests were failing and are now satisfied properly rather than relaxed: the three new `eddi.schedule.*` properties are documented in `docs/configuration-reference.md`, and the new `eddi.schedule.firelog.pruned` counter is both documented in `docs/metrics.md` and charted in the Grafana dashboard, because `MetricsDashboardCoverageTest` requires both.

Recorded honestly as unverifiable locally: the `SafeHttpClient` redirect tests need a loopback socket, and the new DDL and Mongo codec paths are only exercised against real backends in CI.

## 🔁 test(backup): reach the rollback path without a multi-agent archive (2026-09-07)

**Repo:** EDDI (`fix/review-backup-sync`)

Merging main brought in two import-rollback tests from #733 that build an archive with **two** agent files, land the first and fail the second. This branch independently forbids that: `singleAgentFileIn` rejects a multi-agent archive, because an operator who approved importing one agent was getting several, with the `Location` header pointing at whichever file happened to be enumerated last.

Both are right, and as written they cannot both hold. The guard stays; the tests were reshaped to reach the same rollback through a single-agent archive whose **schedule write** fails. That is the first step after the Agent is created and registered, which the production comment at the call site already says is deliberate: schedules carry the id of the agent they fire, so they are written after the Agent but before descriptor bookkeeping, "so a failure there still rolls them back".

Every assertion survives in substance — the Agent is registered, its row is deleted, and it is taken back out of the capability index; and for the second test, a registry that throws on `unregister` still must not abandon the workflow delete that follows it. Proven by deleting `unregisterCapabilities` from the rollback: `Wanted but not invoked: capabilityRegistryService.unregister(...)`.

***

## 🔑 fix(backup): a reordered config list could hand one endpoint another's credential (2026-09-07)

**Repo:** EDDI (`fix/review-backup-sync`)

Three review comments and a round of CodeQL alerts. The first is the serious one.

**Secrets were restored into the wrong entry.** `ScrubbedSecrets.merge` paired source and target list elements by index. An httpCalls config whose entries had been reordered between export and sync therefore kept each source entry's own `uri` while taking the target's value at that position: the billing call kept `https://billing.example.com` and received the *analytics* token. An inserted entry was worse — a newly added call to an attacker-chosen host inherited the credential that had been at its index. That is a credential disclosure to whatever endpoint sits at the other position, not merely a lost secret.

Elements are now bound by stable identity (`name`, `id`, `key`) wherever they carry one, requiring a unique target match. Elements with none — a bare string in an array — fall back to position only when the lists are the same length and the two elements are identical in everything the scrubber did not replace. Anything else refuses and keeps its placeholder, which the caller already logs for the operator. Refusing beats guessing here: a lost secret costs an operator one re-entered key, a misplaced one goes to somebody else's server.

**A workflow-only change was dropped and reported as skipped.** When a workflow diff was `UPDATE` with no extension changes, nothing wrote the source config, so reordered steps or an added condition silently did not arrive — and the run said "skipped", so the operator was told nothing had gone wrong. The executor now adopts the source workflow, but only when every extension reference it carries also exists in the target at the same canonical key; otherwise it refuses and names the step, the same refusal the branch already makes for a new extension. Adopted references are repointed onto the target's own resource URIs, because a cross-instance source names the *other* instance's ids. A source workflow with no steps is refused outright rather than emptying a live pipeline, and an adoption that comes out identical to the target is suppressed so a cross-instance no-op does not burn a version.

**A dispatch test asserted something that could not fail.** It checked `agentUri()` was non-null, which is returned whether or not anything was written, so a miswired store row passed. It now asserts the result carries no failures and that the store was actually invoked; two sibling tests that provoked a failure and asserted nothing about it were strengthened the same way.

**Log injection.** Snippet names, extension types and names, workflow and agent ids all come from the archive and reached the log unsanitized. Every log argument in `UpgradeExecutor` and `SourceUrlValidator` now goes through `LogSanitizer.sanitize`. Most of those values happen to be URI-derived and so cannot carry a control character, but a snippet name is free-form text and genuinely could — that is the one the new test drives.

***

## 🧪 test(backup): replace the tests that could not fail, close the CodeQL round (2026-09-06)

**Repo:** EDDI (`fix/review-backup-sync`)

Two passes on the same branch: 22 review comments (mostly CodeQL log-injection alerts) and a mutation audit of the tests this branch had added.

**Every alert was already closed in the source, but two had no test that could fail if the fix were removed.** Those guards were added. The rest were verified line by line against the working tree rather than against the previous pass's notes.

**The mutation audit is the more useful half.** Fable re-ran each new test with the production change surgically reverted and found several that passed anyway — coverage without a contract. They are now rewritten to assert what the changed line actually implements:

* The snippet-rollback test asserted a call count; it now proves that a snippet created during a failed import is recorded on the `ImportTransaction` and deleted again by `rollbackCreatedResources`, and that a snippet *merged* into an existing one is never deleted.
* `BackupMetrics.upgradeCompleted` was checked by reading counters back, which cannot distinguish `increment(0)` from no call at all — both leave a `SimpleMeterRegistry` counter at zero. It now asserts the calls themselves against a recording registry.
* The workflow pass-through tests asserted a rendered string that a re-serialised model also produces. They now assert the archive's own text survives byte for byte, which catches the real loss: re-rendering adds an `extensions` field the archive never carried.
* The extension-failure test now asserts the exact key set the matcher builds from the target, including the occurrence ordinal, rather than a substring a wrongly-keyed map would also satisfy.

**Recorded gaps, stated rather than papered over.** Two defensive lines cannot be pinned by a unit test and their tests were deleted rather than left as decoration: `recordCreatedSnippet`'s null-URI guard and `resolveSnippetIdsByName`'s null-listing guard both sit inside a broader `catch (Exception)`, so removing either still leaves the class green. A test that cannot fail is worse than no test, because it hides the hole.

Diff coverage of the branch's changed lines: 94.1% line, 84.2% branch.

***

## 🔁 fix(backup): repair agent export, import and sync (2026-09-04)

**Repo:** EDDI (`fix/review-agent-sync`)

From the whole-repository code review. **Granular sync/upgrade did nothing at all**, and its preview said otherwise — the single most serious finding of the review, and it was broken three independent ways at once.

1. `UpgradeExecutor` asked `StructuralMatcher.buildPreview` for a *content-less* preview (`includeContent=false`), so `sourceJson` and `targetJson` were both null for every matched resource. The action then reduced to `Objects.equals(null, null)` → `SKIP`, and the executor skips every SKIP. Every resource that already existed in the target was silently left untouched.
2. Extension URIs were read from `step.getExtensions()`, but the engine stores them in `step.getConfig().get("uri")`, so the target extension map came back empty.
3. The ZIP and remote sources keyed their extension maps by resource-store authority (`ai.labs.rules`) while the target keyed by workflow step type (`eddi://ai.labs.behavior`), so the two sides could never join even with (1) and (2) fixed.

The REST preview endpoints pass `includeContent=true` and therefore showed real differences. An operator saw a diff, applied it, received success, and nothing changed.

**Fixed** by a new `WorkflowExtensions` — one scan that is the single source of truth for how a workflow points at its extension configs. Both producers and the matcher derive keys from it, so source and target are guaranteed to join. The canonical key is `<stepType>#<occurrence>/<path>`, which also fixes a workflow with two steps of the same type collapsing onto one key and repointing both at the second resource. The diff action is now decided from content that is always loaded; `includeContent` governs only what is returned to the caller.

### Also in this branch

* **Exported ZIPs were never deleted** and accumulated in the working directory. They now land in `tmp/archives/` and are swept by age (`eddi.backup.export.retention-minutes`, default 60); the 404 message states the real retention instead of a fictional one.
* **A non-ASCII agent name produced an undownloadable archive.** `URLEncoder` output like `M%C3%BCller+Bot` was written literally to disk, and the download endpoint's character class then rejected the decoded form. Names are slugified instead, and a `Content-Disposition` header carries the readable filename.
* **Schedules were exported but never imported** — silently dropped on every round trip while the ZIP visibly contained them. Import now reads them, repoints them at the new agent with fire bookkeeping reset, and rolls them back with the rest of the transaction.
* **A v5 export ZIP imported nothing and reported 200.** The importer only looked for `.agent.json`, never the v5 `.bot.json` it deliberately still accepts elsewhere.
* **`strategy=upgrade` without `targetAgentId`** silently fell through to create, producing a duplicate agent; it is now a 400 naming the parameter.
* **A selectively-exported ZIP could not be re-imported**: a deselected extension file is absent from the archive while the workflow still references it, and `readResources` handed the resulting null straight to `store.create()`.

### Regression coverage

Every behavioural change is pinned by a test **proven to fail with its fix reverted**. `ExtensionSourceKeyContractTest` writes a real archive to disk and stubs a real remote, then asserts both producers emit identical canonical keys *and* that every extension joins the target as UPDATE rather than CREATE; its fixture deliberately carries two `httpcalls` steps so the collapse-by-type defect is caught too.

Two tests are recorded honestly as characterization rather than guards: the snippet name-fallback row is what `main` always emitted, and `RestUtilities.createConflictException` was a behaviourally identical refactor. Neither can fail without its change, and both say so.

## 🧼 fix(configs): sanitize every cascade-delete log argument, not most of them (2026-09-06)

**Repo:** EDDI (`fix/review-config-delete`)

CodeQL raised eight log-injection alerts on this branch: a REST path parameter reached a log call unsanitized, so a caller could put CR/LF in an Agent or workflow id and forge log records (CWE-117). Every flagged argument now goes through `LogSanitizer.sanitize`.

The more useful part was what the alerts did *not* cover. `RestAgentStore` was left with the same tainted `id` sanitized on one line and raw sixteen lines above it, on the schedule-cascade pair that CodeQL could not reach because it needs the schedule store to throw. Uneven coverage in one file is worse than none, because the next reader assumes the file is done. Every log argument in that class is now sanitized: caller ids, store-sourced ids, and exception messages.

`URI`-typed arguments are deliberately left alone — `URI.create` rejects control characters, so a URI object cannot carry a record boundary in the first place.

Ten regression tests drive a CR/LF payload through the real code paths and assert no newline reaches the log. They guard against vacuity twice: the capture must be non-empty, and it must contain a marker from the specific line under test — otherwise a closed logger or an unreached branch would pass. `captureLogsOf` moved to a shared `LogCaptureSupport` rather than being copied.

One argument is honestly not pinned: `pinned.getId()` on the plan-cascade failure path. It comes from `RestUtilities.extractResourceId`, whose validity gate returns null for anything containing CR/LF, so the value cannot be driven. It is defensive, not reachable.

***

## 🧹 fix(configs): stop cascading deletes removing resources someone else still uses (2026-09-06)

**Repo:** EDDI (`fix/review-config-delete`)

Review round on this branch: 20 comments, 6 fixed, 13 confirmed already correct, 1 disputed with code evidence. The six fixes share one shape — a delete that asked the right question at the wrong moment.

**Cascade deletes checked references before the parent was gone.** `planCascade` has to ask "is this referenced by more than one thing" while the Agent still counts itself, but by the time each child delete actually runs the Agent is deleted and the count has moved. A workflow that a second Agent adopted in between was deleted anyway. Both `RestAgentStore` and `RestWorkflowStore` now re-ask immediately before each child delete, and a candidate that is still referenced increments the `X-Cascade-Skipped` counter instead of being removed.

**The orphan purge trusted a reverse lookup that ignores old versions.** Both lookups in `isReferencedNow` skip referrers that are not a resource's current version, while the mark scan deliberately counts every version a deployment record pins. The purge loop now also re-runs the deployed-agent scan per candidate and fails closed when the scan is incomplete or throws.

**Vault key rotation could sweep the key it had just written.** `versionsToSweep` treated an empty list of valid versions as "nothing to keep" rather than as an unknown bound, so a rotation whose identity update had not yet landed swept the new key. Empty is now handled exactly like null: the full scan range is preserved and nothing is deleted on an unknown bound.

**Soft-deleting a descriptor marked the wrong version.** Descriptor versions advance independently of the resource's, so the soft path now resolves the descriptor's own current version the way the permanent path already did.

**Files:** `RestAgentStore`, `RestWorkflowStore`, `RestOrphanAdmin`, `AgentSigningService`, `RestVersionInfo`, and their tests.

***

## 🛡️ fix(configs): close the CodeQL alerts and the review round (2026-09-04)

**Repo:** EDDI (`fix/review-config-delete`)

Follow-up on the same branch, from two independent review rounds, a diff-coverage pass, and four CodeQL alerts this branch introduced.

**CodeQL, all four fixed in code rather than dismissed.** Two high-severity integer overflows in the new `ResourceUtilities` paging helper, where a caller-supplied index and limit were combined without bounds so a large value wrapped and produced a nonsensical window; the inputs are now clamped before the arithmetic. Two log-injection sites where a user-provided value reached a log statement unsanitised, now routed through the sanitiser the codebase already uses elsewhere rather than a second one.

**A functional regression the branch's own suite could not see.** `RetryConfiguration` gained a total-backoff budget, and every test in that class stayed green while the behaviour changed. Found by the reviewer, fixed, and pinned by a test that fails without it.

**Four tests were proven vacuous by mutation.** One claimed to pin a new `!config.getCapabilities().isEmpty()` guard; removing that guard left it green. Another asserted the consequence of a stub the real collaborator refuses to produce. Each was rewritten to fail on the regression it names, or deleted with an honest gap recorded — a test that cannot fail is worse than none, because it hides the hole.

**Diff coverage** of changed lines: 89.9% to 99.5% line, 80.1% to 92.7% branch, with 38 tests added and each proven against a mutation of the line it protects.

***

## 🗑️ fix(configs): make destructive configuration deletes safe, atomic and honest (2026-09-04)

**Repo:** EDDI (`fix/review-config-delete`)

From the whole-repository code review. The destructive configuration paths destroyed data that was still in use, and reported success while doing it.

**Orphan purge deleted live configuration.** References are version-pinned by design, and `DocumentDescriptorFilter` rewrites a descriptor's `resource` to the new version on every PUT. So a single edit of a rule set leaves the descriptor saying `?version=2` while every workflow that was not re-pointed still says `?version=1`. The scan compared those full versioned URI strings, found no match, classified the config an orphan, and `DELETE /administration/orphans` removed **all** of its versions — destroying a config a live workflow was still resolving. The comparison is now on version-independent identity, so any referenced version protects the resource. The remaining deliberate gap (only the current version of each agent is scanned) is documented rather than silently present.

**Cascade delete tore down workflows before the guard that could still reject it.** A version-mismatched cascade deleted the workflows and schedules of the version it read, then answered 409. And the reference check asked whether *one pinned version* was still referenced before deleting *every* version, so a workflow another agent still used was destroyed.

**Reverse lookups crashed on soft-deleted rows.** `AgentStore` and `WorkflowStore` threw `ResourceNotFoundException` as soon as one referencing document had been soft-deleted, which disabled the "is this still referenced?" check entirely — the check is caught and logged, so cascade delete simply stopped protecting shared resources.

**Version-0 history rows escaped permanent deletion** on MongoDB, so `deletePermanently` and GDPR erasure both reported success while leaving an undeletable descriptor tombstone behind forever.

### Regression coverage

Every behavioural change is pinned by a test proven to fail with its fix reverted.

Three pre-existing tests were **failing on the first attempt at this branch** while the work reported itself green — caught by an independent reviewer running them. They are now genuinely closed, and closed the right way: the fixtures were corrected to stub what production actually calls. Five more tests in the same classes had begun passing *vacuously*, because the change bypassed the dead-letter branch they were written to cover; their stubs are restored so they exercise it again.

`maxMonthlyCostUsd` is stored and displayed but never enforced, because `TenantQuotaService.recordCost` has no production caller. That is left as a product gap and now surfaces as an explicit warning rather than a silent no-op. Two things the auditor caught and this branch corrects rather than ships: the signing keypair was being destroyed from the vault on a **soft** delete, on the path that exists precisely to be recoverable; and a unique compound index was created unconditionally at startup, which fails with a duplicate-key error on exactly the deployments the finding says already hold duplicates. Both are now conditional and safe.

Six pre-existing cascade assertions were flipped from `permanent=true` to `permanent=false`. That inversion is deliberate and correct: permanently removing a shared resource stays an explicit, non-cascading request. A functional regression in `RetryConfiguration`'s new backoff budget was found by the auditor while the class's own suite stayed green, and is fixed with a test that fails without it.

***

## 🐳 fix(docker): move the base image digest and stop the weekly check suppressing itself (2026-09-06)

**Repo:** EDDI (`fix/base-image-digest-and-check`)

`main` has not published an image since 2026-08-30. Every CI run fails at **Scan Docker image for vulnerabilities**, and every job after it — push, cosign signing, SLSA provenance, smoke test, GitHub release, Red Hat catalog publish — is skipped. Reproduced locally with the gate's own flags (`--severity CRITICAL,HIGH --ignore-unfixed`), which exits 1 on six findings, all from the base image:

| Package                           | CVE                                                      | Installed        | Fixed in           |
| --------------------------------- | -------------------------------------------------------- | ---------------- | ------------------ |
| `curl-minimal`, `libcurl-minimal` | CVE-2026-8286 (TLS config mismatch)                      | 7.76.1-40.el9    | 7.76.1-40.el9\_8.5 |
| `curl-minimal`, `libcurl-minimal` | CVE-2026-9547 (SSH host key bypass)                      | 7.76.1-40.el9    | 7.76.1-40.el9\_8.5 |
| `sqlite-libs`                     | CVE-2026-11822, CVE-2026-11824 (FTS5 RCE, heap overflow) | 3.34.1-10.el9\_8 | 3.34.1-11.el9\_8   |

**Digest update, not a stopgap.** The pin was build `1.24-3.1786536503` (2026-08-12). Red Hat republished the tag on 2026-08-24 as `1.24-3.1787587037`; the `1.24` tag now resolves to `sha256:d5f7e0c5…`, which carries all four fixes. Per the remediation procedure in [`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md) this is the clean path — the pin moves, it is never dropped, and no `microdnf update` line is needed. Rebuilt from the amended Dockerfile and re-ran the gate: zero findings, exit 0. Also booted the image against MongoDB and drove a two-turn rule-based conversation end to end (create ruleset → output set → workflow → agent → deploy → start → say), plus the smoke-test assertions CI makes: health `UP` on all four checks, `/openapi` 200, all three security headers present.

### Why nobody was told

`base-image-check.yml` **saw** the new digest on 2026-08-31 and declined to open the PR. Its Dependabot guard matched any open PR whose branch starts `dependabot/docker/`, and #716 — which bumps the *demo* image's `eclipse-temurin` base in `Dockerfile.demo` — satisfies that. This repo has more than one Dockerfile, so the branch prefix was never a sufficient test. The guard now requires the candidate PR to actually touch `$DOCKERFILE`, checked with `gh pr view --json files` and an exact whole-line `grep -qxF`. Verified against the live repo: both open Dependabot Docker PRs (#716, #631) touch only `Dockerfile.demo`, so the guard now falls through and the digest PR would be created.

### Review round — which way the guard should fail

Both CodeRabbit and Copilot flagged the same thing: the guard suppressed `gh` stderr with `2>/dev/null`, so an API error was indistinguishable from "no match". Right, and fixed — stderr is no longer discarded, both `gh` calls have their exit status checked, and each failure emits a `::warning::` annotation naming what could not be read.

Where the two bots disagreed was the *direction* of the failure. CodeRabbit asked to exit the workflow on any API error ("fail closed"); Copilot asked to warn and continue with an empty list. Took Copilot's direction, deliberately: fail-closed here means no PR that week, which is the same outcome as the bug being fixed, whereas degrading toward *opening* the PR risks at worst a duplicate that is visible and closed in one click. Nor is a red job a reliable alarm in this repo — this very workflow failed on 2026-07-13, 07-20, 07-27 and 08-03, four consecutive weeks, with nobody acting on it. When the guard cannot complete it now also writes a "Dependabot guard degraded" block into the step summary, so a duplicate is explained rather than merely appearing.

A second review round caught that the degraded summary was itself conditional: it was gated on `[ -z "$DEPENDABOT_PR" ]`, so if one candidate's file list was unreadable and a *later* candidate matched, the block was suppressed. The skip decision is sound in that case, but a candidate went unchecked and the summary said nothing. The gate is now on the degraded flag alone, with wording that distinguishes the two outcomes. (The `::warning::` annotation always fired either way; only the summary was being hidden.)

A third round caught that the degraded reason was a scalar, so a run where two lookups failed reported only the last one. It is a list now, and the summary prints every failed lookup as its own bullet.

Exercised the rewritten guard against a stubbed `gh` on all five paths: Dependabot PRs touching only `Dockerfile.demo` (no match, PR created — the original bug's correct behaviour), one touching the production Dockerfile (match, skipped), `gh pr list` failing, `gh pr view` failing per-PR, and the mixed case above where the first lookup fails and the second matches. The block is clean under `shellcheck --severity=style`.

Worth recording that the first run of that fifth case printed nothing at all, which looked like the new conditional was broken. It was the *harness*: it locates the end of the guard fragment by matching `if [ -n "$DEPENDABOT_PR" ]; then`, and the fix introduces a nested `if` on the same condition, so the extractor cut the fragment in half and produced an unterminated block. It now matches only at the run-block's own indentation. The same shape of mistake as the `PATH` one in the UBI 10 entry: twice now the test rig has been the thing that broke, and both times it first presented as a bug in the code under test.

The failure mode is worth naming because it is the quiet kind: the weekly job reported **success**, its own summary said the digest had changed, and the outcome line read `Dependabot PR #716 already covers this Dockerfile`. Nothing was red except the thing the automation existed to prevent.

### UBI 10 — evaluated, not adopted

Checked whether the app runs on `ubi10/openjdk-25-runtime`, since Red Hat's own Quarkus material still shows UBI 9. It does. Same UID 185, same `run-java.sh` entrypoint, same `JBOSS_CONTAINER_*` module layout, `curl` and `microdnf` both present, and the *identical* JDK build on both (`25.0.4.1+1-LTS`, Red\_Hat-25.0.4.1.1-1) — so no JVM-level difference at all. The unmodified Dockerfile builds on it with only the `FROM` line changed; the image boots, passes the container `HEALTHCHECK`, serves `/openapi` and `/manage/`, emits an identical set of startup warnings, and runs the same two-turn conversation. Trivy reports **zero** findings at every severity, against 10 HIGH and 223 MEDIUM/LOW on UBI 9. It is also \~19 MB smaller.

One real behavioural difference, and it is in the OS crypto policy rather than the JVM: RHEL 10 additionally disables the static-RSA TLS 1.2 suites (`TLS_RSA_WITH_AES_*_CBC_*`, `TLS_RSA_WITH_AES_*_GCM_*`). Red Hat's OpenJDK honours `/etc/crypto-policies/back-ends/java.config`, and `java -XshowSettings:security:properties` confirms the suites land in the JVM's `jdk.tls.disabledAlgorithms` on UBI 10 and not on UBI 9. Every current LLM provider negotiates ECDHE and is unaffected — TLS to the OpenAI, Anthropic and Google endpoints succeeds from inside the UBI 10 image — but an on-prem endpoint offering only non-forward-secret suites would connect on UBI 9 and fail on UBI 10.

Not switched in this change. The base OS is what `redhat-certify.yml` submits to the Red Hat container catalog, and a major-version move is a certification decision rather than a CVE fix. Kept separate so the digest bump can land immediately and unblock releases.

### Follow-up not taken here

`base-image-check.yml`'s "newer tag" probe only scans `1.25`…`1.29` **within the same repository**, so it cannot surface a UBI 10 image no matter how long one exists. Left as-is; widening it belongs with the decision above.

### Merge with #736 — two guards for the same bug, combined

\#736 landed on `main` first and had independently fixed the same Dependabot false match, so the merge was a conflict between two working implementations rather than a text collision. Neither was strictly better, and each carried something the other lacked.

`main` asked `gh pr list --app dependabot`; this branch asked `--author 'app/dependabot'`. `--app` is right: `--author` is the *user* filter and does not reliably match an App-authored PR, so the candidate list can come back empty, the skip never fires, and the job opens a duplicate — the mirror image of the bug being fixed. `main`'s reasoning about `dependabot.yml` watching three directories (`/src/main/docker`, `/mcp-sidecar`, `/.clusterfuzzlite`) is also the more complete statement of why the branch prefix was never sufficient; the demo image was only the instance that happened to bite.

This branch, in turn, surfaces `gh` failures instead of swallowing them. `main` wrapped both lookups in `2>/dev/null || echo ""`, which restores the original failure mode in a new place: an unreadable candidate silently becomes "not a match", and nothing says so. The three follow-up commits here exist for exactly that.

Resolved by taking `--app dependabot` and `main`'s reasoning into this branch's structure, so the guard both queries correctly and reports when it could not. Verified the merged workflow parses as YAML and that all six `run` blocks pass `bash -n`.

**The digest now applies to two `FROM` lines.** #736 split the image into a throwaway `docs` stage plus the runtime stage, both carrying the pin. Its own comment requires the two to move together — `base-image-check.yml` parses the last `FROM` and its `sed` rewrites every line carrying the pin — so bumping one would leave the vulnerable base in the published image and silently desynchronise the automation. Both moved. `EddiImageDockerfileTest` (also new in #736) pins the integration image to the production file and uses synthetic digests rather than the real one, so it required no change.

**Files:** [`src/main/docker/Dockerfile`](https://github.com/labsai/EDDI/tree/main/src/main/docker/Dockerfile/README.md), [`.github/workflows/base-image-check.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/base-image-check.yml)

***

## 🐧 chore(docker): move the production base image to UBI 10 (2026-09-06)

**Repo:** EDDI (`chore/ubi10-base-image`)

Companion to the UBI 9 digest bump on `fix/base-image-digest-and-check`, which is the immediate release unblock. This is the durable fix: `ubi9/openjdk-25-runtime` carries a standing CVE backlog that a digest bump only ever partially drains, and `ubi10/openjdk-25-runtime` is currently **clean at every severity**.

| Base                                           | CRITICAL/HIGH | MEDIUM/LOW | Layer size |
| ---------------------------------------------- | ------------- | ---------- | ---------- |
| `ubi9/openjdk-25-runtime:1.24` (newest digest) | 10            | 223        | 145.7 MB   |
| `ubi10/openjdk-25-runtime:1.24`                | **0**         | **0**      | 127.0 MB   |

**The JDK does not change.** Both images ship Red Hat OpenJDK `25.0.4.1+1-LTS` (`Red_Hat-25.0.4.1.1-1`), so this is strictly an OS-layer move — no bytecode, JIT or GC behaviour differs. UID 185, the `run-java.sh` entrypoint, the `JBOSS_CONTAINER_*` module layout, `curl` (needed by `HEALTHCHECK`) and `microdnf` (needed by the stopgap escape hatch in `AGENTS.md`) are all present and identical. Only `JAVA_HOME` moves, from `/usr/lib/jvm/jre` to `/usr/lib/jvm/java-25-openjdk`, and nothing in this repo reads it.

### Verified, not assumed

Built the image, ran the CI image gate with its own flags (`--severity CRITICAL,HIGH --ignore-unfixed`) → zero findings, exit 0. Booted it against MongoDB: container `HEALTHCHECK` healthy, health `UP` on all four checks, `/openapi` 200, `/manage/` 200, all three security headers present, Red Hat certification labels and `/licenses` intact. Drove a two-turn rule-based conversation end to end — create ruleset → output set → workflow → agent → deploy → start → say — against a UBI 9 control on the same build; identical output and an identical set of startup warnings.

### Two RHEL 10 constraints, both documented in the `FROM` block

**1. Host CPU floor rises to x86-64-v3.** Verified by reading `GNU_PROPERTY_X86_ISA_1_NEEDED` out of each image's `libc.so.6`: UBI 9 declares `x86-64-v2`, UBI 10 declares `x86-64-v3`. So the host needs AVX2, BMI2 and FMA — Intel Haswell (2013) or AMD Excavator (2015) onward. Every current cloud instance type clears it; a pre-2013 bare-metal host does not, and glibc refuses to start rather than failing later. `libjvm.so` carries no ISA note (HotSpot probes the CPU at run time), so glibc is the binding constraint.

**2. Static-RSA TLS 1.2 suites are disabled.** RHEL 10's crypto policy adds `TLS_RSA_WITH_AES_{128,256}_{CBC,GCM}_*` to the disabled set, and Red Hat's OpenJDK inherits `/etc/crypto-policies/back-ends/java.config` — confirmed with `java -XshowSettings:security:properties`, where those suites appear in the JVM's `jdk.tls.disabledAlgorithms` on UBI 10 and not on UBI 9. Every current LLM provider negotiates ECDHE and is unaffected; TLS to the OpenAI, Anthropic and Google endpoints succeeds from inside the image. An on-prem endpoint offering only non-forward-secret suites would connect on UBI 9 and fail on UBI 10.

Both are stated in full in the `FROM` block of [`Dockerfile`](https://github.com/labsai/EDDI/tree/main/src/main/docker/Dockerfile/README.md), so the next person to touch that line sees them without leaving the file, and summarized in [`docs/redhat-openshift.md`](/deployment-and-infrastructure/redhat-openshift.md) for operators.

### Red Hat support boundary

A container supplies its own userspace, so the host only has to be new enough. Red Hat's [container compatibility matrix](https://access.redhat.com/support/policy/rhel-container-compatibility) lists a UBI 10 image on a RHEL 9 host as **Supported**, subject to the conditions that apply to any mismatched major pair: the workload runs unprivileged, does not interact directly with kernel-version-specific interfaces (`ioctl`, `/proc`, `/sys`, routing, iptables, nftables, eBPF), and the image's RHEL version stays within its supported lifecycle. EDDI meets these — UID 185, nothing below the JVM. The condition an operator has to plan for is the last one Red Hat states and the one a support ticket runs into: a reported issue may have to be reproduced in a fully compatible configuration — that is, on a RHEL 10 host — before it is investigated. RHEL 8 is the one host Red Hat marks unsupported for a UBI 10 image. `redhat-openshift.md` states all of this.

*(An earlier draft of this entry claimed the RHEL 9 host combination was categorically outside Red Hat's policy. It is not; the matrix was checked and the claim corrected before merge.)*

### `ContainerBaseIT` no longer restates the base image

> **Superseded on merge by #736.** `main` reached the same conclusion first and went further: `EddiImageDockerfile.forTestContext()` transforms the *whole* production Dockerfile for the test build context, rather than parsing the `FROM` line and re-stating the remaining twenty lines inline. That is strictly stronger — the inline copy this branch kept could still drift in every respect except the base image — so the parser described below was dropped in the merge rather than reconciled. `EddiImageDockerfileTest` now pins the relationship, and it uses synthetic digests, so moving the production pin to UBI 10 required no change to it.
>
> The account below is kept because two of its findings outlived the code: static initialisers run in textual order (a constant used by a container field must be declared above it, and only a real container IT catches the violation), and container ITs *do* run on this machine. Both are recorded in the Regression Notes.

`ContainerBaseIT` builds its own inline Dockerfile that mirrored the production `FROM` — on UBI 9, and unpinned, so it was already a major version and a digest behind what shipped. Hard-coding UBI 10 there would only have reset the clock: the copy goes stale on the next digest bump without anything failing, and the container ITs quietly certify an OS layer nothing ships. It now parses the `FROM` line out of `src/main/docker/Dockerfile` at test time, which carries the digest pin along for free and makes drift impossible rather than merely discouraged.

A later review round hardened that parser. The first version took the first whitespace-separated token after `FROM`, which is the image reference today but would be the **flag** the moment the file gains `FROM --platform=$BUILDPLATFORM …` for a multi-arch build. The ITs would then have built against a Dockerfile reading `FROM --platform=$BUILDPLATFORM` and failed confusingly rather than clearly. It now skips `--flag` tokens, and matches the instruction with a case-insensitive `^\s*FROM\s+` rather than `startsWith("FROM ")`, since Dockerfile keywords are case-insensitive and may be followed by a tab. Checked against thirteen shapes: both real Dockerfiles in the repo, `--platform` with and without a trailing `AS`, a lowercase keyword, a tab separator, a leading indent, a `# FROM` decoy comment, multi-stage last-wins, and the two malformed inputs that must throw.

**The hardening broke the build first.** `FROM_INSTRUCTION` was declared below the `EDDI` container field, and static initialisers run in textual order — so that field's initialiser called into the parser while the pattern was still `null`, and all three container ITs died with `ExceptionInInitializerError` caused by a `NullPointerException`. Neither `test-compile` nor the standalone parsing harness could see it: one does not run static initialisers, the other does not reproduce this class's field order. Only the Integration Tests job did. The constant moved above the container fields, with a Javadoc saying why it must stay there. Verified by reproducing the mechanism in a two-class scratch file (declared-after throws, declared-before does not) and then by running `AgentUseCaseIT` locally end to end: 2 tests, 44 s, image built from the parsed `FROM` line, container healthy.

That local run is itself worth noting, because a standing assumption said container ITs cannot run on this machine. They can. Had that been checked earlier, the null pattern would have been caught before the push rather than by CI.

Worth recording how nearly that verification went wrong. The first harness reported the tab-separator case failing, which looked like a bug in the new regex. It was not: the harness had been written through a shell heredoc, which ate one backslash from `"\\s"`, and **Java 15 accepts `\s` in a string literal as an escape for a plain space** — so the corrupted harness compiled cleanly and silently tested `^ *FROM +` instead of `^\s*FROM\s+`. A compile error would have been kinder. The harness was rewritten to a file directly and its regex diffed against the real one before being trusted.

### The check that could not have told us

`base-image-check.yml`'s tag probe only walks `MAJOR.MINOR+1…+5` **within the pinned repository**, so `ubi10` was invisible to it no matter how long it existed — the job would have reported `1.24 is the latest tag` forever. Added a `Check for newer UBI major` step that derives the `ubiN` segment from the image path and probes `ubiN+1` and `ubiN+2` (trying the pinned tag, then `latest`, so a renamed tag scheme still registers), plus a matching issue notification whose body lists what to confirm before a major move: CPU baseline, crypto policy, JDK build, and `ContainerBaseIT`. Verified against the live registry — `ubi11` and `ubi12` are 404 today, and running the same logic against the old `ubi9` pin resolves to `ubi10`, which is exactly the miss it closes.

### Review round

Four findings from CodeRabbit and Copilot, all taken.

**The RHEL 9 host claim was wrong** — the strongest reason to run a review. CodeRabbit disputed the "categorically outside Red Hat's policy" wording and it was right; the matrix says **Supported**. Corrected in both the doc and this entry rather than quietly reworded.

*A second round then caught that the correction had left the page arguing with itself.* The opening bullet still said the image is "supported by Red Hat when run on RHEL or OpenShift" and the note below the platform table still said "any RHEL-based platform", while the new paragraph three lines further down said RHEL 8 is unsupported. An operator reading top-down would have been sent to RHEL 8 before ever reaching the caveat. Both broad claims are now bounded, the "runs anywhere with a container runtime" statement is explicitly separated from the *supported* configuration, and the platform table gained explicit RHEL 9 and RHEL 8 rows so the boundary is visible where support levels are actually looked up.

**`ContainerBaseIT` should carry the digest, not just the tag** — raised by both bots. Taken further than asked: rather than restating the digest in a second place, the test now parses the production `FROM` line, so the class of drift the bots were pointing at cannot recur.

**A hard-coded version series in operator guidance** (Copilot) — "6.3.x and earlier" would have gone stale immediately. The corrected paragraph names no version at all.

**`actionlint` SC2001/SC2086/SC2129 on the new step** (CodeRabbit). Fixed: bash regex and parameter expansion instead of `echo | sed`, quoted `"$GITHUB_OUTPUT"`/`"$GITHUB_STEP_SUMMARY"`, grouped consecutive appends. Both new steps are now clean under `shellcheck --severity=style`, which the seven pre-existing steps in the file are not — those are left alone here rather than folded into a base-image change.

Extracting the new step and running it against a stubbed `skopeo` was worth doing: the first harness reported "no newer major" for all three cases, which looked like a real bug in the rewritten parameter expansion. It was the harness — a Windows-style directory on `PATH` that Git Bash cannot resolve, so the stub was never found and every probe failed identically. The step itself is correct on all three paths (pinned on ubi9 finds ubi10, pinned on ubi10 finds nothing, a non-`ubiN` image exits early).

### Merge with #736 and #737

\#736 landed on `main` while this branch was in review and changed three of the things it touches.

**The move now applies to two `FROM` lines.** #736 split the image into a throwaway `docs` stage and the runtime stage, both carrying the pin, with a comment requiring them to move together — `base-image-check.yml` reads the last `FROM`, but its `sed` rewrites every line carrying the pin. Both stages are on UBI 10. Moving one would have published a UBI 9 layer and desynchronised the automation at the same time. The UBI 10 rationale block now sits above the stage comments rather than immediately above a single `FROM`, since it governs both.

**The `ContainerBaseIT` parser was dropped**, superseded by #736's `EddiImageDockerfile` — see the note in that section above.

**The Dependabot guard was reconciled in #737, not here.** That branch merged first and combined `main`'s `--app dependabot` filter with its own failure reporting; this merge inherits the combined version and adds only the newer-UBI-major probe, which is orthogonal — the tag scan walks `MAJOR.MINOR+1…+5` inside the pinned repository and so cannot see a new major at all. The one textual collision was the summary step, where `main` had quoted `"$GITHUB_STEP_SUMMARY"` for shellcheck and this branch had added an unquoted UBI-major line; the added line is now quoted. Verified the merged workflow parses as YAML and that all eight `run` blocks pass `bash -n`.

**A whole-file `--ours` silently dropped an unrelated fix, and CI caught it.** Resolving the `FROM` collision with `git checkout --ours src/main/docker/Dockerfile` took *this branch's entire file*, not just its side of the conflicting hunk — discarding every non-conflicting change the other side carried. What went with it was #734's audit provisioning:

```dockerfile
RUN mkdir -p /opt/eddi/data &&       chown -R 185:0 /opt/eddi &&       chmod -R 775 /opt/eddi
```

That directory is the default `eddi.audit.dead-letter-path` parent. UID 185 cannot create a directory under `/opt` at run time, so without it every dead-letter write on the documented `docker run` quick start throws into a swallowed catch and the abandoned audit entries are gone outright rather than recoverable — the exact defect #734 had just fixed.

`AuditDeadLetterImageProvisioningTest` failed on it in CI. It did not fail locally because the pre-push run was a hand-picked `-Dtest` list built around the files the change was *believed* to touch, and the whole point of this failure mode is that it touches files you did not intend.

Fixed by rebuilding the file from `#737`'s and re-applying only the three intended edits, so the result is provably that branch's Dockerfile plus the UBI 10 move, rather than a patched-up copy whose provenance nobody can check. Then audited every file this branch differs from `#737` in, reading the **deleted** lines specifically: all remaining deletions are UBI 9 text replaced by UBI 10, plus one renumbered header comment. `ContainerBaseIT.java` is byte-identical to `main`'s.

The general rule, worth stating because the conflict markers actively invite the mistake: `--ours` and `--theirs` operate on **files, not hunks**. They are only correct when one side's entire file is wanted, as it was for `ContainerBaseIT.java` here. Where a file has both a conflicting hunk and non-conflicting changes from the other side — which is the normal case — edit the markers, or rebuild from the other side and re-apply the intended delta.

**`AGENTS.md`'s base-image bullet was corrected rather than merged.** It described `ContainerBaseIT` as parsing the `FROM` line, which stopped being true in this merge. It now names `EddiImageDockerfile.forTestContext()` and states the two-stage rule.

**Files:** [`src/main/docker/Dockerfile`](https://github.com/labsai/EDDI/tree/main/src/main/docker/Dockerfile/README.md), [`ContainerBaseIT.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java), [`.github/workflows/base-image-check.yml`](https://github.com/labsai/EDDI/tree/main/.github/workflows/base-image-check.yml), [`AGENTS.md`](https://github.com/labsai/EDDI/tree/main/AGENTS.md), [`docs/redhat-openshift.md`](/deployment-and-infrastructure/redhat-openshift.md)

***

## 🎲 test(caching): de-flake the cache-wide TTL expiry test (2026-09-06)

**Repo:** EDDI (`fix/flaky-cache-ttl-test`)

`CacheFactoryTest.cacheWideTtlStillExpires` reddened CI on an unrelated branch (run `34047771699`, `expected: <value1> but was: <null>`, 1 failure in 20,556 tests). A rerun of the same commit passed, so this was a race, not a regression.

The test built a cache with a **1 ms** cache-wide TTL, wrote an entry, and read it straight back:

```java
ICache<String, String> cache = factory.getCache("cacheWideTtl", Duration.ofMillis(1));
cache.put("key1", "value1");
assertEquals("value1", cache.get("key1"));   // loses if the thread stalls for 1 ms
```

One millisecond of scheduling stall between two adjacent statements is unremarkable on a shared runner, and that is all it takes. The behaviour under test — that the entry eventually expires — was never in question.

### Why the fix is a wider margin and not a fake clock

`CacheFactory` builds Caffeine inline and exposes no `Ticker` seam, so a deterministic clock would mean adding a test-only seam to production code. That is not worth it here, because the deterministic coverage **already exists**: `CacheImplTest.DefaultTtlTests` drives a `FakeTicker` over a directly constructed `CacheImpl` carrying `WriteExpiry.of(Duration.ofSeconds(60))` and pins the semantics exactly — expiry on an untimed put, reads not extending it, re-writes restarting it.

What that deterministic test cannot see is the factory itself: it constructs `CacheImpl` by hand, so `CacheFactory.getCache(name, ttl)` could stop installing the policy entirely and every assertion there would stay green. That wiring check is this test's actual job, and it is worth keeping. So the TTL moves to **500 ms** with a 1.5 s sleep past it. The test costs \~1.5 s, which against a 20,556-test suite is nothing.

### The read that looks redundant is the one holding the test up

The obvious "fix" is deleting the pre-expiry read, since the test is named for expiry. That would make it pass vacuously: with nothing asserting the entry was ever stored, a `put` that silently dropped the write would satisfy `assertNull` just as well as a working TTL. Its neighbours avoid the race by writing a *second, untimed* key as their non-vacuity guard and only reading after the sleep — but a cache-wide TTL expires everything, so no such key exists here and the guard has to be a read before expiry. That is why this one test needs a TTL its siblings do not, and the constant's Javadoc says so, with the failing run id.

### Both assertions mutation-checked

Per the house rule that a behavioural test is not trusted until it has been seen to fail:

| Mutation                                                                  | Expected                 | Result                                                                                                         |
| ------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `getCache(name, ttl)` installs `WriteExpiry.never()` instead of `of(ttl)` | the entry survives       | fails at `assertNull` — *the cache-wide TTL must still remove the entry, expected `<null>` but was `<value1>`* |
| `CacheImpl.put(K,V)` stores nothing                                       | the entry is never there | fails at the pre-read in 0.002 s — *the entry must be readable before its TTL elapses*                         |

Both production files were restored afterwards; this change touches **test code only**.

`perEntryLifespanOverridesCacheTtl` was checked as well, as suspected: it writes a 1 ms entry and an untimed one into a **1 hour** cache and reads neither before sleeping, so it has no race. Same for `perEntryTtlIsHonoured` and `negativeLifespanIsUnlimited`.

**Files:** [`CacheFactoryTest.java`](https://github.com/labsai/EDDI/tree/main/src/test/java/ai/labs/eddi/engine/caching/CacheFactoryTest.java)

***

***

## 🔀 fix(build): repair `main` while merging it into the v5 compatibility branch (2026-09-06)

**Repo:** EDDI (`fix/review-legacy-compat`)

Merging `origin/main` to clear a conflict on this branch surfaced that **`main` itself is red**, and has been since the merge of #728. Two independent breakages, neither this branch's doing, both fixed here because the merge inherits them and the PR cannot go green while they stand — the same call the workspace-properties entry recorded on 2026-08-30.

**1. `McpToolsProviderTest` and `McpToolsProviderDiscoveryTest` do not compile.** `2377cd045` ("read configs from stores, not the authoring facade") changed `McpToolsProvider` to take `IAgentStore`/`IWorkflowStore` and updated those tests' imports. `f314d47cd` (#725) then added test code still using `IRestAgentStore`/`IRestWorkflowStore` — types the file no longer imports. Two commits that each pass alone and fail together, which is exactly what a merge queue is meant to catch. Migrated to the store interfaces: `readAgent`/`readWorkflow` become `IResourceStore.read`, and the one test that now calls a throwing method declares it.

**2. `ImportStyleTest` fails on `main`.** `RestScheduleStoreTest` carries two inline `io.quarkus.security.ForbiddenException` references, which is the exact convention that test enforces. No other `ForbiddenException` is in the file, so a plain import is unambiguous — no `ALLOWED` entry needed.

### The conflict itself

`main` had independently added `@JsonAlias("workflowExtensions")` to `WorkflowConfiguration` — a partial version of this branch's fix. This branch's alias is a superset that also covers `packageExtensions`, the key 5.6.0 actually persisted and the one a v5 ZIP carries, so the branch's version wins and `workflowExtensions` remains covered. The second conflict was an import collision in `DynamicAgentGuardrailResolutionTest`; both imports are needed and both are kept.

### Copilot review

One non-blocking comment: `LegacyDocumentMigrations`'s Javadoc called the transforms *pure* while every one of them mutates the supplied `Document` in place. Corrected to state the in-place contract, that the return value is the same instance or `null` for "nothing changed", and that callers must pass a freshly deserialized mutable document. The matching `@DisplayName` is updated too.

***

## 🔬 test(configs): pin the v5 compatibility guards against mutation (2026-09-04)

**Repo:** EDDI (`fix/review-legacy-compat`)

Follow-up to the v5 compatibility fix on the same branch, from an independent review round.

`OutputItem` registered `AgentFaceOutputItem` twice — once per type id — which forced `OutputItemTemplatingTest` to loosen its subtype-count assertion. Collapsed onto Jackson's `names` attribute so one class has one registration, and the original `assertEquals(8, subTypes.value().length)` guard is restored.

`PostgresMigrationManagerParityTest` was named for a parity it never checked: it pinned the PostgreSQL bean in isolation and never instantiated `MigrationManager`, so a divergent transform re-inlined into either backend would have kept it green. It now runs one legacy fixture through both managers and compares.

The Javadoc on `LegacyDocumentMigrations.output()` claimed the stored-document rewrite normalizes `botFace` away. It does not: the Mongo sweep is gated on a migration-log row every already-started deployment holds, and the PostgreSQL manager never swept at all. The alias is therefore **permanent**, and both it and `AgentFaceOutputItem.LEGACY_TYPE_ID` now say so — without that note the next maintainer could retire the alias as redundant and silently re-break every un-resaved v5 output set.

**Diff coverage.** Changed lines went from 98.9% to 100% line and 86.8% to 100% branch, measured by intersecting the branch diff with JaCoCo per-line data. The project's own gate is bundle-level across 175k lines and cannot see uncovered new code. Sixteen tests were added and each was proven by mutating the line it claims to pin and confirming it fails — one caught a `-2147483649` round-tripping back as `2147483647`.

***

## 🧬 fix(configs): keep v5 stored configurations loadable (2026-09-04)

**Repo:** EDDI (`fix/review-legacy-compat`)

From the whole-repository code review: the one compatibility contract this project promises to keep — stored JSON configs and exported ZIPs keep loading — was broken in the direction that loses everything silently.

`WorkflowConfiguration.workflowSteps` carried no alias for the key EDDI 5.x actually persisted. `PackageConfiguration` wrote `packageExtensions` up to and including 5.6.0, and `SerializationCustomizer` deliberately pins `FAIL_ON_UNKNOWN_PROPERTIES=false`, so the old key was dropped without a word: the workflow deserialized to **zero steps**, `WorkflowStoreClientLibrary` happily built an executable workflow from the empty list, and the agent *deployed successfully* while running no parser, no behaviour rules and no output for the rest of its life. No exception, no warning, no failed deployment.

Fixed by `@JsonAlias({"packageExtensions", "workflowExtensions", "pipelineSteps"})` on the setter. The two intermediate names never reached a released database, but keeping them is cheaper than being wrong about which of them did.

The same shape existed in the output model: the polymorphic type id was renamed `botFace` → `agentFace` with no alias and no `defaultImpl`, so a v5 output set carrying a `botFace` item was unloadable. `OutputItem` now registers the retired id as a subtype alias of `AgentFaceOutputItem`.

Two further compatibility gaps, both found by the review's cross-cutting pass:

* **Migrations were MongoDB-only.** `MigrationManager` held the legacy document rewrites as private helpers, so a ZIP imported against PostgreSQL skipped them entirely and the same archive produced different agents per backend. The rewrites moved into a new backend-neutral `LegacyDocumentMigrations`, which `PostgresMigrationManager` now applies too; `PostgresMigrationManagerParityTest` pins that both managers perform the same set.
* **The strict-boundary sweep skipped the evidence.** `StrictBoundaryShippedConfigsTest` counted `.bot.json` and `.package.json` fixtures as *skipped* rather than checked — precisely the two file kinds that would have caught the missing aliases. It now parses them.

**Regression coverage.** Every behavioural change is pinned by a test proven to fail with its fix reverted. `WorkflowConfigurationLegacyAliasTest` reads the repository's own v5 fixture and asserts the step count, and it first asserts the fixture still contains the v5 key — so the test cannot quietly pass while guarding nothing. With the alias removed it fails with `expected: <6> but was: <0>`.

Note for anyone repeating this exercise: proving a test fails without its fix requires touching the restored file's timestamp. Maven compiles incrementally by mtime, and both `git checkout` and `Move-Item` restore an *older* one, so the test silently runs the previously compiled class and the proof is worthless.

***
