September 2026
Archived entries for September 2026, newest first. For recent work see the live changelog.
🐛 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 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 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 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 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 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 now deletes nothing while V6RenameMigration.holdsRetention 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), 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:eddi.migration.backupBeforeWritewrites<collection>.premigrationbackup, and only for the legacy-format step, not.historyfor every migration.output-templating.md: templates are converted only witheddi.migration.v6-qute.enabled=true.eddi.migration.v6-rename.enabledis now inapplication.properties.The new
upgrading-from-5x.mdcovers 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 andSUMMARY.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. 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: addedclaude-opus-5-5andclaude-sonnet-5-5, listed first.bedrock: addedglobal.anthropic.claude-sonnet-5-5(onbedrock-runtimethe bare model id is not accepted for on-demand inference; theglobal.inference profile is) andanthropic.claude-opus-5-5(kept bare: its inference-profile id is unconfirmed).Default model is now
claude-sonnet-5-5(wasclaude-sonnet-5) wherever the Manager fills one in: the wizard provider table (anthropic, andglobal.anthropic.claude-sonnet-5-5for Bedrock), the group wizard fallbacks, the Operator's default config, and the conversation-summary and Dream model defaults. Thee.g. claude-sonnet-5placeholders (llmEditor.cascadeModelName,Workforce.wizard.modelPlaceholder) follow, in all 11 locales.openai: addedgpt-6-solandgpt-6-lunaaftergpt-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-openaiuntouched: 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
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.jsondeclares the eight providers (xai,deepseek,moonshot,qwen,zhipu,minimax,openrouter,groq): endpoint, regions, default model, suggestions, parameter defaults, extra body fields and capabilities.OpenAiCompatibleProvidersloads and validates it (duplicate id, blank URL/model, non-https URL fail startup);LlmModuleregisters oneOpenAiCompatibleLanguageModelBuilderper entry, each delegating toOpenAILanguageModelBuilder.Precedence. Explicit
baseUrl, then theregionparameter, then the provider default.modelNamefalls back to the provider default. A missingapiKeyfails with a message naming the provider and a${vault:<id>-key}example.OpenAILanguageModelBuildernow readsreturnThinking/sendThinking(default false, sotype: openaiis unchanged) and has publicbuild/buildStreamingoverloads 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
returnThinkingandsendThinkingtrue, 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 historicalreasoning_contentback during tool use, and MiniMax (which the preset sendsreasoning_split: true) puts thinking intoreasoning_contentand wants the full assistant message appended to the history. Qwen sets neither.returnThinkingonly matters together withsendThinkingtoday, 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.
JsonResponseFormatPolicyandModelCapabilityServiceread each provider's JSON-mode and vision-model tokens from it (DeepSeek's vision isdeepseek-flash/deepseek-v4-flash; a unit test checks that no vision token matches a known text-only model such asqwen3.7-maxorglm-5.3).AgentSetupService.resolveParamsno longer defaults these providers toclaude-sonnet-4-6; it uses the provider's own default model. Thesetup_agentandcreate_api_agentMCP tools name the eight ids, and their model example is nowdeepseek-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
regionparameter stays for hand-written configs. A type the Manager does not know renders as " (custom)" instead of being rewritten.llm-provider-catalog.tsmirrors the backend JSON, andllm-provider-catalog.test.tsreads that JSON and fails on any drift. Twelve i18n keys in all 11 locales.No new dependencies and no migration: existing
type: openai+baseUrlconfigs 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
modelNameshould be set explicitly in production. Alibaba's docs now favour workspace-specific hosts for Singapore and Beijing; the shareddashscope-intl/dashscopehosts are kept as region defaults.HITL: a paused tool call round-trips the assistant message's
thinking(a newChatTranscriptCodecTestcase 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 inlangchain.md.Local baseline:
hitl-config-negotiation.test.tsfails 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/usregion pair (the US host servesgrok-4.7andgrok-4.6only, at about a 10% premium) andgrok-4.6. DeepSeek's default is nowdeepseek-flash(V4.1 Flash, 2026-09-10, multimodal). Moonshot's key URL is the console page andkimi-k2.7-codeis vision-capable. Qwen drops the legacyqwen3.7-max/ supersededqwen3.6-flashsuggestions, its vision tokens no longer match the text-onlyqwen3.7-max, and it sets no thinking defaults. Z.ai GLM sets both thinking flags and listsglm-4.6vandglm-5.3-flashfor vision (glm-5vis unconfirmed). MiniMax'scnregion isapi.minimax.cn(the old host 302-redirects, which can drop the POST body/Authorization) and it sets both thinking flags. OpenRouter'sopenrouter/autois vision-capable. Groq moves toqwen/qwen3.8-27b(qwen3.6-27bwas shut down 2026-09-14). Sources: the vendor docs linked fromlangchain.md.Default model outside the builder.
AgentSetupService.defaultModelForis the one rule (preset default, elseclaude-sonnet-4-6);resolveParamsandCreateSubAgentTool(including itsallowedModelsguard) 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.McpSetupToolsno longer callsclaude-sonnet-4-6the 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/customParametersfails 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
LlmModuleTestcovers 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
cnhost, 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.
OperatorConfiggained an optionalllmBaseUrl, 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'shttp://localhost:11434used 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 gainedleadingOptions(for their "None" / "Workforce default" choices),ariaLabeland a "Select a provider" placeholder for an empty value. The wizard label is bound to its select.ProviderRegionSelecttakes aclassNameandhideLabel, so the operator form renders it inside its own field chrome with matchingh-10 rounded-mdstyling; its custom option reads "Custom URL (set below)".LLM_PROVIDERSis type-checked againstProviderGroupwithsatisfies.LLM editor. The endpoint hint resolves in the backend's order (
baseUrl, then theregionURL, 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}toProviderSelect(new prop), keeping main's rule that setup cannot provisiongemini-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-argumentsetupAgent(request, origin); the one-argument form would have made thenever()check vacuous.operator-activation.test.tsx's fallback case (added on main) expectedclaude-sonnet-5while #896 moved the default toclaude-sonnet-5-5— main's own suite is red on it. It now derives the expected model fromLLM_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)
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-genaiwas never released past1.20.0-beta30, and the pom forbids splitting the two langchain4j lines, since a split surfaces as a runtimeNoSuchMethodError.
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, 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 installs.
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 threeDeploymentManifestsTestPowerShell tests that fail identically onorigin/mainhere.Manager: lint, typecheck,
i18n:check, andvitest run --coverage(426 files, 6,858 tests, no unhandled errors) all pass, as doesvite 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 sameDeploymentManifestsTestPowerShell tests, whose files the merge leaves identical toorigin/main.ReactorNettyNettyCompatibilityTest(new): passes on 1.2.18 and fails on 1.3.7 withNoClassDefFoundError: 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-bomships.Testcontainers 2.0.5 is exactly what the 3.39.5
quarkus-bommanages. 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
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 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, MongoTestBase.java, MongoConversationMemoryStoreTest.java, PostgresTestBase.java, ContainerBaseIT.java, PostgresAgentUseCaseIT.java
Verification
./mvnw compile/test-compilegreen (Checkstyle andformatter:validateincluded).MCP unit tests (
Mcp*Test): 1377 run, 0 failures.Every container-backed store test (the
MongoTestBase/PostgresTestBasesubclasses, the tenant-quota parity tests,MongoConversationMemoryStoreTest) against real Docker containers: 564 run, 0 failures.Container ITs (
verify -DskipITs=false):AgentUseCaseIT,A2aEndpointIT(theContainerBaseITimage, which boots the extension in real Quarkus),PostgresAgentUseCaseITandNatsConversationCoordinatorITall green.McpEndpointITagainst that same image: 5 of 5.McpCallsCrudIT/PostgresMcpCallsCrudITcould not run on the Windows workstation — a nativemongodalready 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
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
.classin 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.
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 compileafter each commit../mvnw package -DskipTeststwice: with Node 24 on plugin 1.15.1, then on plugin 2.0.2. Both installedv24.21.0intoui/node, rannpm ciandnpm run buildfor both UIs, and endedBUILD SUCCESS. Under Node 24's npm 11.19,npm ciwarns thatesbuildandmswinstall scripts are "not yet covered by allowScripts". They still run: themswpostinstall rewrotemockServiceWorker.js, which was restored afterwards.Chat, in a Linux
node:24.21.0container: typecheck and vitest pass (15 files, 278 tests).Manager, in a Linux
node:24.21.0container: lint, typecheck andi18n:checkpass. 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,ComposeStackTestandJlamaRuntimeFlagsTestall pass. The exception is the threeDeploymentManifestsTestPowerShell-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
Filereaches the MSW handler with an empty body.request.formData()then fails undici's own assertion on theFileit 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 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)
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, fix pending in #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-assignmentfound 13 initialisers that every branch overwrites (for examplelet nextIndex = nullahead of an exhaustiveswitch). They are now bare declarations.preserve-caught-errorfound one error that dropped its cause. The operator's "rollback ALSO failed" error now carries the rollback failure ascause, andtsconfig.app.json/tsconfig.design-sync.jsongain theES2022.Errorlib 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:
set-state-in-effect
66
refs
36
immutability
7
purity
6
static-components
2
preserve-manual-memoization
2
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 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 typecheckandnpm run i18n:check(4,507 keys, 11 locales) pass.npx vitest run --coveragepasses: 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 buildpasses 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 rannpm ciand the build for both UIs, and left no change in either lockfile.
Next
Remove jsdom's
~30.0.1pin 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
@Startupbootstrap module (ApiCallsModule,RagModule, …) in the@LifecycleExtensionsmap; 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 —
MyFeatureTaskwas both the task class and the element typegetActions()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 isrunPostResponse(memory, postResponse, templateData, httpCode, validationError)), and discarded the mapexecutePreRequestPropertyInstructionsreturns, so later templating would run on stale data.§4.2 named
memory.getCurrentData(...),addConversationOutput(...)andAgentOrchestrator.buildToolList— none exist — and abuiltInToolsfield (the fields areenableBuiltInTools/builtInToolsWhitelist, and a new tool joinsBuiltinToolsProvider's catalog). Tool assembly is now described asbuildToolSetup→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 inConversationMemoryUtilities.convertSimpleConversationMemory(returnDetailed=truereturns everything). The instruction now says so, since "set it false to hide it" is the wrong lesson.§4.1 rule 5 named a
ConversationCoordinatorclass — it isIConversationCoordinator, in-memory or NATS.§4.3 / §4.6 assumed MongoDB and
@QuarkusTest. Stores extendAbstractResourceStoreoverIResourceStorageFactory(MongoDB and PostgreSQL; themongo/package names are historical) and inherit@ConfigurationUpdate; task unit tests are plain JUnit 5 + Mockito. "Use Java records" is gone too: every configuration class underconfigs/is a POJO with getters, andLlmConfigurationis 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 awip: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, a published page (added toSUMMARY.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 — inReservedActionLint,WorkspaceAccessIndexMigration, three tests and four published pages (conversation-memory,properties,httpcalls,open-webui-integration) — now names the new page, andDocumentationAccuracyTest/DocumentedRestPathsTestgrade the page instead ofAGENTS.md.docs/langchain.mdtaught the same nonexistentbuildToolListdiagram 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, notcodeql.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-generatedclaude/*branch before the first commit), a note that-Dtestruns 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.mdis 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.mdcarries 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 staleEDDI-integration-testsrepo row and the eight-directory file tree are replaced,renderPageis 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.mdand the three skills: the hard-coded counts (11 primitives, 14 shared, 25 components, per-page tallies) had all drifted and are gone;ResizeHandle,ChipInput,StepDotsand the connection-reference components, the Buttonwarningvariant and thewarning/popover/ringtokens are documented;eddi-datano longer describes a pre-commit hook that was removed.ui/chat/AGENTS.md: the backend emits nine SSE events, not eight —tool_callhas been sent since 2026-08-14 and this widget does not handle it yet (a code follow-up, not done here). Knownerrorevents carry acode. The query-parameter list is complete, the approvals route is the Manager's/manage/approvals, the personalc:\dev\gitpath is gone, and the CI gates (typecheck,lint,test) are named.
Tooling
.cursor/rules/project-context.mdcpoints at the nestedui/*/AGENTS.mdfiles..claude/skills/ship-pr/SKILL.mdlost a citation of another project's pull request and two "this changed" remnants.
Follow-ups
Handle the
tool_callSSE event inui/chat(SSEEventTypeinsrc/types.tsand the eventswitchinsrc/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, returnsnullfor 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 transientactiveToolplusSET_ACTIVE_TOOL.FINISH_STREAMINGandCLEAR_MESSAGESclear 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— handlestool_calland clears the tool on every token and ontask_failed, and at the start of each new turn.Indicators.tsx—ThinkingIndicatortakes atoolprop ("Using calculator…", with lucide'sWrenchin place ofBrain, the icon set #875 moved the widget to). It is a prop on the same component rather than a new one, for the reasonescalatingis: 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 thattool_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, unliketask_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, chat-store.tsx, ChatWidget.tsx, Indicators.tsx, types.ts, 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); 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): 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 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.
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, ScrubbedSecrets.java, StructuralMatcher.java, UpgradeExecutor.java, RestImportService.java, RestExportService.java, ImportPreview.java, DocumentDescriptor.java, sync-page.tsx, preview-step.tsx, agent-sync-guide.md, 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, AbstractBackupService), 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.scanDocumentkeys 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.NestedReferencesthen swaps each reference in the parser for its matched counterpart, by source id.UpgradeExecutorwrites 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.
extractResourcesUrisreturned 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.
RestImportServiceimports 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 acreate(the rule for every other type) or refusing amergethat 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 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 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
ingestionIdthat wrote it, and after the new chunks are stored the others under that name are removed withingestionId != current. Chunks written before this change carry noingestionId; the in-memory store and Chroma both match a missing key withisNotEqualTo, so those are superseded too (tested against the real in-memory store; checked by hand against Chroma 1.0).Needs an explicit name —
400without one, because every unnamed document sharesunnamedand 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. 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(onlyurl/loc; the image, video and news extensions' ownlocs andxhtml:linkhreflang 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 absolutehttp(s)URLs count.Where sitemaps come from — new
web.sitemapUrls(at most 20, each validated likestartUrl: http(s), not a private literal address) for a site that publishes one without listing it, or a crawl withrespectRobotsoff; then robots.txt, where a relativeSitemap: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. 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
taskErrorsentry strict write'sdigestmode writes. It is caller-facing only: nothing readstaskErrorsback into the model's view, and no data ortask_failed_*action is written, so agent prompts are unchanged. Under strict write the configuredonFailurestill decides —exclude_allstays silent.With the actual reason —
LifecycleManager.describeFailureappends the most specific cause to the wrapper's message and pullsmessageout of a provider's JSON error body. It feeds both the digest (redacted, URLs removed, 200 chars) andsummarizeForAudit, so the streamingtask_failedevent 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
doneevent carry) keep only whitelisted output keys, andtaskErrorswas not one of them. The entry was written and then stripped, so the fix above did nothing on its own, and strict write'sdigestmode has never reached a default client either, whateverdocs/memory-policy.mdsaid. Found by running the change live; the unit tests, which stop atLifecycleManager, passed throughout.MemoryKeys.TASK_ERRORSis 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 anERRORturn 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
ERRORturn, 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. 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),replacevalidation, andsitemapUrlspersisted 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 carrytaskErrors. Reverting either fix fails them (deleteFile; both failure-report tests).IntegrationTestProfilefollows-Dquarkus.mongodb.devservices.port. With a locally installed MongoDB on 27017, DevServices could not bind and every@QuarkusTestIT failed at startup;-Dquarkus.mongodb.devservices.port=27018now 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 answersreplace=truewith 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 insrc/test/mocks/handlers.ts(TASK_FAILURE_TRIGGER, a new/agents/:id/streammock), becausepage.routecannot see requests the MSW service worker answers.Fixed on the way: the knowledge-base ingestion panel's
mountedRefwas setfalseby its effect cleanup and never back totrue. React StrictMode mounts, unmounts and remounts in development, so every status poll returned immediately and every direct ingestion showed "processing" for ever undernpm 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-warningtoken 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.
concurrentReplacementsOfOneNameLeaveOneVersionforces the interleaving and ends with zero chunks without the lock.Manager: the streaming back-fill overwrote the failure notice when an
ERRORturn also carried text (CodeRabbit). The notice is now appended after it; tested, and the test fails with the old order.EmbeddingStoreFactory— the Chroma builder loggedbaseUrl, tenant, database and collection unsanitized (java/log-injection);sanitizeCollectionstripped trailing underscores with_+$, quadratic on a long run of them (java/polynomial-redos). Both pre-existing onmain; nowsanitize(...)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
timeBudgetMinuteswhile 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 recordsstaleAfter(claim time + the budget it runs under + 15 minutes) on the run — astaleAfterfield in MongoDB, astale_aftercolumn in PostgreSQL added byADD COLUMN IF NOT EXISTS— andreapStaleRunsjudges a run with a deadline by that alone. Runs claimed before the upgrade keep the start-time rule.IngestionStateStoreContractgainsrecordedDeadlineGovernsandpassedDeadlineIsReaped, run against the in-memory, MongoDB and PostgreSQL stores (42/42 each, the latter two in Testcontainers);reservationRecordsItsDeadlinefails if the pipeline claims without one.CodeQL
java/unreleased-lockon the replace lock: the conditionallock()/unlock()pair is now a plainlock(); try { … } finally { unlock(); }on its own branch.Store URLs in logs (CodeRabbit):
sanitize()neutralises control characters but keptuser:password@and query-string tokens. The Chroma and Elasticsearch builders now logendpointForLog(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.xmlfor 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
SQLExceptionin aRuntimeException— left both versions retrievable behind a barefailed:status. It is still reported as failed (a retry removes every version but its own), with awarningsaying 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.xmlis 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 undernpm run devit would get the SPA'sindex.htmlwith a 200 and report online even with the backend down./q/*is also often blocked at the ingress./q/health/readygoes 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)inlib/access.tsreads the level from the version descriptors each page already loads. Sincemainmoved version lists to per-id reads (GET /descriptorstore/descriptors/{id}?version=,getDescriptorVersions), those are what carry the level.RestDocumentDescriptorStorestamps it throughredactUnlessOwner. It only uses the descriptor whose URI resolves to thisid, becausefilter=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
idcomes back, the lookup is empty or partial. That counts as unrestricted only when/workspacesreports enforcement off, because unmigrated data there can legitimately return no descriptors and owners must keep Delete. With enforcement on, before/workspaceshas answered, or when it failed, nothing is offered. The pages read the newuseSpaces().enforcement, which staysundefinedon a failure.enabledfolds a failure intofalse, 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.
useAgentVersionsnow also returnsresourceandcallerLevelin 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:accessForDetailoffers 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/workspacesanswers, 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/workspacesfails, and shows it when workspaces are off or/workspacesreturns 404. The negative cases wait until both the descriptor and/workspacesqueries 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.tsxandresources.test.tsxhad 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
enabledagain, the/workspaces-failure test fails.
Files: use-platform-status.ts, access.ts, use-agents.ts, agent-detail.tsx, workflow-detail.tsx, 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:
A new toast reaches the Toaster through
setTimeout(() => flushSync(...)).A dismissal is applied in a
requestAnimationFrame.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, awaited by the sharedafterEachinsetup.tsbeforecleanup(). Every deferred callback comes from a mounted Toaster's subscription, sodrainToasts()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()insideact, 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
MutationObserverrecords every Toaster the test mounts, andtakeRecords()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
waitForwould 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.tsxtests the actual contract: no sonner callback outlives the drain. It wrapssetTimeout,clearTimeout,requestAnimationFrameandcancelAnimationFrame, tracks the callbacks scheduled from sonner's code (by stack), and asserts none is still pending afterdrainToasts(). 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)andrequestAnimationFrame.resource-detail-save-not-live.test.tsx: its own undrainedtoast.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 ownafterEach. Mergingmainin, 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'safterEachis 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 lintandnpm run typecheckpass.
🔖 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'seddi.image.tagis now empty and falls back toappVersionthrough a neweddi.imageTaghelper (_helpers.tpl, used bydeployment.yamlandNOTES.txt). Chart2.1.0 → 2.2.0, a non-breaking change: an explicit tag still wins.k8s: the base Deployment no longer names a tag.
kustomization.yamlpins it throughimages:and setsapp.kubernetes.io/versionthroughlabels:(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 withErrImagePullrather than pullinglatest. Every overlay and example was re-rendered withkubectl kustomize; the only difference from before is the version label on the base's other resources.Manager:
package.jsonhas noversionany more.vite.config.tsreads the first<version>frompom.xml(the ruleci.ymluses) when Maven'sEDDI_VERSIONis not set, sonpm run devand 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.jsonsays 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,-b42andx.y.z.w.scripts/bump-version.pysupports:showandcheck.next, which sets the pom and compares a suffixed pom such as7.0.0-SNAPSHOTby its numbers.release, which moves every pointer plus the chart version: a minor, or a patch for a patch release, together withEXPECTED_CHART_VERSION.release <current> --forcerepairs 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. Bothreleaseandpost-releasevalidate every value they will parse or rewrite before the first write, so a refusal never leaves a half-applied bump.
ReleaseVersionSourceTestreads the same JSON. Every pointer must name the chart'sappVersion, 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.jsoncarries 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.mdline on its first run, and was mutation-checked against it.BumpVersionScriptTestruns 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 Pointersjob inci.ymlrunsbump-version.py checkon any change underdocs/,k8s/,helm/orREADME.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 theoperator_docsfilter so their Java tests run.
Automation. New 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 "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/**tooperator_docswould 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 onCHANGELOG_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 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, andGET /workspaces/directoryprovides autocomplete. The directory takes part in GDPR erasure and export through a newIGdprParticipanthook.Listings take
ownership=mine|shared. This is backed by a newIResourceFilter.NotMatchingfilter:$noton Mongo, andCOALESCE(...) !~on Postgres, where a missing field counts as not matching.Create in a space. A request can name its space in the
X-EDDI-Spaceheader.SpaceHeaderFilterrefuses a space the caller is not a member of before anything is written.Runtime settings.
default-spaceandlegacy-visibilitylive inworkspace_settingsand can be changed withGET/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/spacemoves 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/requestsasks 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.conversationReviewis 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-frontendweb origins are nowhttp://localhost:${EDDI_PORT:7070}/https://localhost:${EDDI_HTTPS_PORT:7443}, anddocker-compose.auth.ymlpasses 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;
internalvisibility;move to team;
a chat link.
A notification bell, and a request-access panel on detail pages.
A
/manage/workspacespage for settings, space secrets and variables.A conversation-review editor section and a chat notice, in both chat UIs.
Operator activation now sets
internalvisibility.101 new i18n keys, in all 11 locales.
Fixes found on the way
Ownership transfer from the Manager sent
user:bobas 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/chataddress only once an agent is published. Found on the live instance, where a/chatlink 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
"", andname ?? idonly 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.
requestAccessanswered 202, andApiClientdiscards 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.GlobalVariableResolvernow 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 indocs/workspaces.mdthat 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/variablesnow 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 answeredSENT. It now answersSENT, and every attempt counts toward the 20-a-day limit, not only the delivered ones.Legacy visibility failed open. It fell back to
sharedwhen the settings store was unreadable at startup, even if an admin had setadmin-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;
/workspacesand the notification endpoints are open to every signed-in role;space variables written by tenant id through
/variablestoreare never exportable;the review-policy cache expires after write;
X-EDDI-Spaceis 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>orteam:<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;
internalsnippets 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:connectionGrantsand 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'sSharingChangedEvent, 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 thegcloud compute ssh … --command='sudo cat …'to read it. This also retires the/dev/urandomfallback 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_rulenow compares source ranges and updates the rule. The port-80 rule alone is0.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:8180rule is no longer created.Keycloak is published on loopback (
KEYCLOAK_BIND, default127.0.0.1) indocker-compose.auth.yml, so itsadmin/admindev default is never reachable off-host by accident.install.shlogs 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 installlines now carry the required values. A livehelm upgradeof a release whose MongoDB predates authentication refuses to render until the user is created (mongodb.authMigrated, procedure indocs/kubernetes.md). MongoDB credentials are URL-encoded in the connection string.auto-approve-copilot.ymlalso 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 said releases are tagged v6.0.0, v6.0.1 and v6.0.0-RC1. That contradicts AGENTS.md §1, 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.3comments on pinned GitHub Actions.The existing warnings against the prefix, in
release-versioning.mdandrelease-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: 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 -nfrom--argvalues, with no inline interpolation, and a comment at the payload records the limit.BuildQualityGatesTestslackSectionsStayWithinTheFieldCapchecks two things:every
fieldsarray innotify-slackhas at most 10 entries;every
--arg *_iconthe 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 * * 1event, 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, andPersist metrics historywrote 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:
Digests are computed from the
metricsbranch 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.jsonon themetricsbranch 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_URLnothing 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=falsewhen they carry a value forward, andPersistthen leaves the day's row to a later run. The GitHub stats step gets the same digits-only guard as Docker Hub.git ls-remotenow 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;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_completecarry the task'sgetType()(langchain,behavior_rules,httpCalls, …), and the row looked that up as aneddi://extension id. It never matched, so every step rendered as the unknown type in grey. The newextensionTypeForTaskinextensions.tsmaps the backend's runtime types (read from the task classes, not from the mocks, which useai.labs.*ids) to extension types.The Audit trail keyed its badges on
behavior,httpcallsandpropertysetter, 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
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.
Sparklesstays 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: no two sidebar destinations may share an icon.resource-type-icons.test.ts: the icons in the shared table are distinct, everyRESOURCE_TYPESslug is covered, and"constructor"does not resolve.chat-activity-icons.test.tsxand the newextensionTypeForTaskcases: 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). 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). 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). The confirm button is disabled only after a re-render, so a quick double click sent/endtwice. 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). 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,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 calledonConnectedand 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
ResourceAlreadyExistsfallback).
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
UserConversationis untouched.The HTTP layer normally rejects anonymous callers first.
IRestAgentManagementis@RolesAllowedand the catch-all path policy isauthenticated, so this in-method gate is defence in depth. It is reached whenauthorization.enabledor 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, RestAgentManagementExtendedTest.java, 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
langcontext;the
HashMapwas 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), andloadReplaceEnded;nullLanguage: the engine still receives a null-valuedlang;nullInitialContext.
Each test asserts two things:
the deployment's
initialContextequals its value before the call and has nolangkey;the map captured on
startConversationWithContextis a different instance, still carries the designer-set entry, and haslangwith 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, 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: 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: 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 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.
dockerpublishes, tags, signs and attests the image, but itsneedslistede2e-fullstackand note2e-auth. A push to main whose only failure was the Keycloak tier still shipped. It now needse2e-authande2e-gate.BuildQualityGatesTest.dockerNeedsEveryE2eJobchecks that every job the gate grades, plus the gate itself, is in docker'sneeds.B3: fragments passed the Java test but failed the collator. The Changelog Discipline job now runs
python3 scripts/collate-changelog.py --checkon every PR.ChangelogFragmentTestalso 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-DDheadings, so an undated heading or a setext (----underlined) heading went through. The whole job was also skipped for any head branch namedchore/collate-changelog, a fork's included.The guard now counts every
##heading, which is whatsplit_sectionstreats 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-changelogbranch skips the guard, and that branch gets its own step that fails if it changes anything besidesdocs/changelog.md,docs/changelog.d/*,docs/changelog/*.mdanddocs/SUMMARY.md.ChangelogDisciplineJobTestpulls both steps' shell out ofci.ymland runs it under bash, with a stubgitthat 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-nodesaid22, whilepom.xmlvendorsv22.23.2for 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_VERSIONnow feeds everysetup-node.Build Image places the archive at the path the plugin's
RepositoryCacheResolverresolves (~/.m2/repository/com/github/eirslett/node/<v>/node-<v>-linux-x64.tar.gz) and verifies it againstNODE_SHA256_LINUX_X64, taken from nodejs.org'sSHASUMS256.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.nodeVersionIsPinnedOnceAndVerifiedkeeps ci.yml,pom.xmlandmise.tomlin step.A local
./mvnw packagestill trusts nodejs.org over TLS, as documented inpom.xml. So doessrc/main/docker/Dockerfile.demo, which runsmvn packagewithout-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:
.gitignorere-included nested.claude/directories.!.claude/was unanchored, while.claude/*is root-only, soui/chat/.claude/settings.local.jsonwas not ignored. The three root rules are now anchored with/. The Manager'sui/manager/.claude/had the same shape: the whole directory was re-included, so aworktrees/checkout created there was addable. It is now re-excluded apart fromskills/andlaunch.json.nestedClaudeDirectoriesStayIgnoredasksgit check-ignoredirectly..gitignorejoins thebackendpath filter so a change to it runs that test.B7: vitest security updates were not grouped. A group without
applies-tocovers version updates only. Both UIs now have avitest-securitygroup (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 forvitestalone can still leave@vitest/*behind. The comment independabot.ymlsays so and says what to do then.ui/chat had no ESLint. Landed on
mainfirst (#856,4fdf4bedd) while this branch was open; this branch now carriesmain'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:1622held a literal U+0000 in a string, and so didui/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.tsfails on any raw control character undersrc/,e2e/andscripts/.L7: README dev commands lacked the Jlama flag. Every
quarkus:devcommand in README, AGENTS.md, CONTRIBUTING.md,docs/getting-started.mdanddocs/developer-quickstart.mdnow passes-Djvm.args=--add-modules=jdk.incubator.vector, asmise run devdoes, with a note that onlyjlamaagents need it.JlamaRuntimeFlagsTest.documentedDevCommandsPassTheFlagchecks all of them except CONTRIBUTING.md, which is in nobackendpath filter, soBuildQualityGatesTestforbids a test reading it.A5: realm test gaps in
DeploymentManifestsTest.A client that omitted
standardFlowEnabledwas 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-leveloauth2DeviceAuthorizationGrantEnabled) 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 -sffailed 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.mddescribe the new guard and the--checkstep.The Node-pin test requires every workflow that uses
env.NODE_VERSIONto 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, dependabot.yml, BuildQualityGatesTest.java, ChangelogDisciplineJobTest.java, ChangelogFragmentTest.java, DeploymentManifestsTest.java, JlamaRuntimeFlagsTest.java, 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.padDataLinesprefixes everydata:line with one space, and the parser kept it, so every token gained a space ("quota tion"). The parser inchat-api.tsnow strips exactly one; a token's own leading space follows it. The test that enshrined the old behaviour is rewritten.High 13, environment ignored.
startConversationnow 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
errorframe carrying a pre-turn refusalcode(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 bysse-events.review.test.ts. A mid-turn failure (no code) still stays as an error on the sent message.rerunLastStepno longer sendslanguage=en. The backend already treats it as optional; the hard-coded value became the conversation'slangproperty.
Secret input (High 6, Chat UI side)
A requested
inputFieldis read from the streamingdoneframe too, not only the non-streaming snapshot, so the password field appears on the default transport and the key is sent withsecretInput.Only
subType: "password"is masked and flagged secret;textandemailare ordinary fields (SecretInputused 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.storeUserInputInMemorywrote<secret input>as the displayedinput, butInputParserTask— the first task of practically every workflow — overwrote it with the normalized plaintext, andinput:initialwas never masked. Only ascope:"secret"property scrubbed them. So a key sent withsecretInputstayed in the stored step, in every snapshot, in the audit ledger (TurnAuditBufferredacts only wheninput:initialreads as the placeholder), and in the streamingdoneframe once theinputkey was exposed.Conversation.scrubSecretClientInputnow runs when the turn stops (completed, stopped, paused or failed), before the audit flush:it sets
input:initialandinput:normalizedto the placeholder;it clears the parsed expressions and intents derived from the secret;
it re-asserts
<secret input>as the displayedinput;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
secretscope vaults it instead). A task that runs after a HITL resume of the turn sees the placeholder. Tests:ConversationSecretClientInputTest, with the realInputParserTaskin 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.convertSimpleConversationMemorymasksinputin the outputs andinput:initial,input:normalizedandexpressions:parsedin the steps of any turn flaggedsecretInput(the output'scontext.secretInput/ the step's owncontext:secretInputdatum, 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 rawinput:initial, without a migration. The stored document is not rewritten. Tests:ConversationMemoryUtilitiesTest.oldStoredSecretTurnIsMaskedOnRead,oldStoredSecretTurnEchoesAreScrubbedOnRead.PropertySetterTaskno 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.convertSimpleConversationMemorynow keeps the exactinputkey in non-detailedconversationOutputs. Its contract is "the masked display copy":<secret input>for a turn flaggedsecretInput, the normalized text otherwise. It is additive; readers look up specific keys. The Manager'ssnapshotToMessages(fromfix/manager-chat) reads this key, and the engine fix above is what makes that correct too.RestAgentManagement.loadConversationMemoryre-runs the last step only when the caller asked for a language. A load without?language=compared the storedlangagainst 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.imageoutput items are rendered by the widget, http(s) or same-origin only.applicationLinkbecomes a link (label escaped, destination wrapped in<…>) andbuttonshows 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 tofonts/, which the Maven build already copies and prunes.MessageBubbletakes 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"witharia-live="polite"andaria-busywhile a reply streams.useThemeguards 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=Nwith 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
lintscript and aLintstep in theUI ChatCI 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
pendingAttachmentseffect to run. The upload resolves outsideact, 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'ssetImmediatevssetTimeout(0)ordering, which changes under CPU load.Fix: the intent now carries the removed
storageRefand 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,intentsandproperties:extractedread as empty lists, and the output'sexpressionsandintentsentries are dropped. Onlyexpressions:parsedwas 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_PROPERTYcapture skips the placeholder too. It readsinput:initialdirectly and storeduser_input, so after a RULE-pause resume of a secret turn it saved the literal<secret input>. It now goes through the placeholder guard (isScrubbedPlaceholdersince the merge with main) like thevalueStringandfromObjectPathcaptures. Tests:PropertySetterTaskSecretScrubTest.catchAnyInputSkipsThePlaceholder(fails with the guard removed) andcatchAnyInputStoresOrdinaryInput. The helper's Javadoc no longer sits betweenautoVaultSecretand 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.mdno 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
isSafeUriresolves a slash-prefixed value against the page before calling it same-origin:/\evil.exampleis a path to another host in an http(s) URL.useThemekeeps the mode in force in a ref, so with blocked storagesetTheme("system")keeps following OS changes instead of falling back toinitial.Chat UI
stepsToMessagestrusts 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 owninput:initialreads<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'sscrubSecretUserInputand 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 ascope:"secret"property has already replacedinput: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 tocreate_agenton a later turn, into the literal<secret input>.ConversationSecretInputTest.secretInput_renderedCopiesScrubbednow asserts the property is kept; thesecretscope 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
inputandproperties, and in the pending tool-call batch (newSecretValueScrubber.scrubTypedTokens). From 4 characters the substring search is unchanged.ConversationSecretClientInputTest.shortSecretIsScrubbedFromEchoesnow expects "ok abc" to become "ok ". The audit ledger and the read-time masking keep the 4-character substring search only.SecretValueScrubber.scrubDeepTokensno longer renames map keys. It replaced a short secret in keys as well, so a PIN "id" would have renamed everyidfield 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
returnDetailedredaction (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 showsproperties.apiKeyandproperties:apiKeyas<REDACTED>; the non-detailed read andconversationPropertiesare unchanged.PropertySetterTask. One guard,isScrubbedPlaceholder(name, value, scope): a value that is exactly<secret input>is not stored, in any scope, and forscope:"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 inwithInstructionVisibility; thefromObjectPathstring capture keeps that and this branch's placeholder guard. Main's newproperties:changesundo log is aproperties:*datum, so, like the property mirrors, it keeps a captured input — undo restores the value the designer captured.ConversationSecretInputTest.secretInput_renderedCopiesScrubbedskipsproperties:*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'sreplaceStatehelperstripTokenFromUrlis removed, because it re-ran on the stripped URL and reset the token. Main's allow-listapiServersanitizer and postMessage token handshake are kept; its control-character regex carries an ESLint exemption.snapshot.tskeeps oneSECRET_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_MESSAGESclears theconversationIdthe 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
conversationIdor 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-userstill sees the configuredtitleinstead 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, itslastSteprules, 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).ConversationStepRunnernow 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 aTurnBuilderthatsay()/sayStreaming()hand to the runner in place of a finished callable, because theConversationcaptures 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 RESTsayendpoint 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.getRevisionis 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
/resumefailed. 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
conversationStatecache 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, becauseendConversationruns 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()calledgetAgent(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
groupIdsonPropertyand cleared the redo cache on a new turn. Main landed both fixes independently (group scoping viaConversationGroupsat the persistence boundary; the redo clear inConversationMemory.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'sfinallyon 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
submitNextcould not schedule is now told throughIDiscardableTask.onDiscarded, as the in-memory coordinator already did. Before, the REST caller waited for its 408 and an SSE stream hung. (2)routeToDeadLetterand the ordering publish also catchRuntimeException. AnIllegalStateExceptionfrom a closed connection escapedsubmitNextand 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) andeddi.nats.stream-max-bytes(256 MiB).16 MB — persistent heartbeats grew without bound. A
conversationStrategy: persistentschedule 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: witheddi.schedule.persistent-conversation-max-stepsset (default0, off), a conversation that has reached that many steps AND is idle (READY, ERROR, EXECUTION_INTERRUPTED) is ended and replaced by a new one. Theconversation-scoped properties are copied into the new conversation (what its own start turn set wins).longTermproperties 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.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.
replaceOnerewrites the first matching row, which is the oldest. The dedupe kept the newest, so it deleted the row every deploy/undeploy had written to.setDeploymentInfonow stampslastModified. The dedupe keeps the most recently stamped row, and among rows written before the stamp existed, the lowest_id, i.e. the onereplaceOnewas 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
sayendpoint'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.
Files: ConversationStepRunner.java, ConversationService.java, IConversationMemoryStore.java, ConversationMemoryStore.java, PostgresConversationMemoryStore.java, Conversation.java, NatsConversationCoordinator.java, ScheduleFireExecutor.java, AgentDeploymentManagement.java, 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/persistedVisibilityandUserMemoryEntry.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 asself. Dropped this branch'sProperty.groupIdscarry (and itsPropertyInstruction/MemoryCheckpointplumbing, the baseline-adoption helpers inConversationand 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
stepScopedPropertyIsDroppedWhenTheTurnFailsagainst main's code). Ported onto main'sConversation: the turn'sfinallyblocks drop them on every exit but a HITL pause;postConversationLifecycleTaskskeeps main'srecordPropertyChanges, 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_refusesAConversationOwnedByAnotherUserand…_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;
createPropertiesHandlercalls in the turn builders take main'sisMemoryToolsEnabled()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
lastModifiedanddeploymentStatusthe 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
checkDeploymentsat 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/vieweranduser/user, which logged straight in (temporary: trueis ignored on import), and the publiceddi-frontendclient allowed the password grant — onecurlreturned a token that could run LLM turns. No account ships a credential now, and the grant is off oneddi-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.ymlhard-coded the Keycloak bootstrap admin asadmin/adminon0.0.0.0. It now publishes on127.0.0.1(KEYCLOAK_BINDto change it). (This branch also made the password a requiredKEYCLOAK_ADMIN_PASSWORD; at reconciliation main'sKC_BOOTSTRAP_ADMIN_*with a loopback-onlyadmindefault was kept — see the entry below.)install.sh/install.ps1generate the admin password and keep it in.env. They rotate a legacyadmin/adminlogin to it on re-run, and giveeddia one-time password (Keycloak forces a change at first login) instead of printing theeddi / eddithe realm stopped seeding.--demo-users/-DemoUsersdoes the same forvieweranduser. 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.shreads 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 (verifiedrequiredActions: ["UPDATE_PASSWORD"]), idempotence on re-run, andunauthorized_clientfor the password grant. The PowerShell run found that PS 7'sInvoke-RestMethodemits 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.passwordis required. The credentialed connection string reaches EDDI as a mounted file from the<release>-mongodbSecret. The ConfigMap's credential-less entry now renders only for the explicitmongodb.auth.enabled=false: a file listed inQUARKUS_CONFIG_LOCATIONSinherits that env var's ordinal (300, confirmed in SmallRye'sAbstractLocationConfigSourceFactory), so the two would tie.networkPolicy.datastores.enabled(default on) admits only the EDDI pod to MongoDB, PostgreSQL and NATS.Kustomize:
overlays/mongodbauthenticates from an operator-createdmongodb-secrets, and removes the base's credential-less connection string.overlays/mongodbandoverlays/postgreseach ship an EDDI-only NetworkPolicy.postgres-secret.yaml(committededdi/eddi, applied bypostgres-ha) becamepostgres-secret.yaml.example.quickstart.yamlkeeps 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 agrafana-adminSecret (k8s); in composeGRAFANA_ADMIN_PASSWORDkeeps main'sadmindefault 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).
/chathas its own CSP filter, withframe-ancestorstaken fromeddi.chat.frame-ancestors(default'none').X-Frame-Options: DENYmoved 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-srcallowsblob:, so staged attachment previews render.connect-srcappendseddi.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_accessadvice was checked against Keycloak — the scope and role exist after import, but clients request onlyscopes_supported. The doc now gives the two ways that work.A7:
AuthStartupGuardnamed the workspaces claim as quarkus-oidc's default claim. It now namesgroups, and reports a collision only when there is one.B1:
Dockerfile.democopiesui/, somvn packagecan build the UIs the demo relies on.
What this breaks — upgrade paths
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. 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, withprintf, because--set-filekeeps a trailing newline as part of the value. The sequence was extracted, checked withbash -n, and run against stubkubectl/helm.helmMongoUpgradeCommandIsWellFormedpins it.--reuse-values: the new switches were read as(… | default dict).enabled, so a missing key — exactly whathelm upgrade --reuse-valuesfrom 2.x produces — meant off. The upgrade exited 0 with MongoDB still open and no datastore policy. Both switches now go througheddi.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 twoexpect_failurecases and a positivenetworkPolicy.datastores=nullrender.helmSecuritySwitchesDefaultOnWhenAbsentpins the wiring.Installers:
The admin passwords are now carried forward in
.envwhatever the flags, and.envis written underumask 077.A legacy
.envon 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 legacyviewer/usercredentials.Without a TTY, the one-time password goes to a 0600
first-login.txtinstead 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.yamlonce, behind an[ -e ]guard, and reuse it for every upgrade. The Kustomizemongodb-secretssnippets skip creation when the Secret exists and warn against re-running.HTTP-level CSP check:
InfrastructureIT.chatAndManagerFramingHeadersasserts that/chatgets one CSP header and noX-Frame-Options, and that/manageand/q/health/readykeepDENY. 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.ymlrequiresKEYCLOAK_ADMIN_PASSWORD. The CI-onlydocker-compose.integration-keycloak.ymlkeepsadmin/adminas a documented test-tier exception.
Second review follow-up (same day)
Grafana credential on the fresh / stopped-stack path: Grafana reads
GF_SECURITY_ADMIN_PASSWORDonly when it creates its database. So agrafana-datavolume from an earlier install keeps its own admin password when.envhas none and the installer generates one. The installers probedadmin/admin, returned silently when it was refused, and printed a success banner pointing at a password Grafana rejects. Nowconfirm_grafana_login(install.sh) andConfirm-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 legacyadmin/adminlogin as before, and confirm that Grafana acceptsGRAFANA_ADMIN_PASSWORD. If it accepts neither, they stop when this run generated the value, with the.envline to fix. The stack is left running, so a re-run takes the already-running path. A value that came from.envor 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.shsends 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
.envis written beforeresolve_stack_passwordsgenerates the Grafana password, does not hold..envis written inresolve_compose_files, whichmain()calls afterresolve_stack_passwords.install.ps1has the same order (Resolve-StackCredential, thenGet-ComposeFiles). There is nowrite_env; a comment that named one now namesresolve_compose_files.Tests:
DeploymentManifestsTest.InstallerGrafanaCredentialpins both installers' order: passwords resolved, then.envwritten, then Compose started. It also pins thatinstall.ps1confirms the Grafana login betweenWait-ForReadyandWrite-Success. It runsinstall.sh's two Grafana functions against a stand-incurlthat plays a Grafana: a matching password, a legacyadmin/adminvolume (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: nopwshwas available locally, andInvoke-RestMethodcannot be stubbed from PATH.
Third review follow-up (same day)
A failed
admin/adminchange no longer counts as confirmation. Ininstall.sh, a refused change only warned and returned. Ininstall.ps1, a still-validadmin/adminlogin returned beforeGRAFANA_ADMIN_PASSWORDwas 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.envkeeps 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
.envhad 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.envfor that check.No jq or python3:
install.shbuilds the change request by hand when the password is alphanumeric, which every generated one is. So a host without either tool can still moveadmin/adminto the generated value instead of hitting the stop.Tests:
shellConfirmsTheGrafanaLogingains three runs against the stand-incurl: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.ps1is 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 aui/chatchange, left to the UI branches.M-F4: the wizard's fetch-spec-by-URL still needs
eddi.csp.extra-connect-sourcesset; the default stays strict, and File/Paste work. A friendlier UI message is aui/managerfollow-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 updatehas no pre-flight check for the new admin passwords; compose's own error names the missing variable.
Files: realms, docker-compose.auth.yml, docker-compose.monitoring.yml, install.sh, install.ps1, helm/eddi/, k8s/overlays/{mongodb,postgres,monitoring}, application.properties, AuthStartupGuard.java, 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 thevote/votesvalues are read, never the free-textstatement, 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'sresolveChoicematch 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'smemoryGuardrails.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_completedefect above. A related gap stays open: withonAgentFailure: 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 inGroupConversationService: (1) the cadencelaunch()block — keep this branch; (2, 3) the two catch blocks — keep this branch (it adds thestopIfEndedElsewherere-read after the lines both branches share); (4)persistCancelled— keep this branch's conditionalpersistWhileRunning; (5) thediscussionControlsfield — take #834'serasureStepName/stopInFlightWork. The sixth is one line inGroupConversationServiceBranchCoverageTest, where this branch carries #834's deleted-while-running tests with the Gone stub moved toupdateIfState— keep this branch.GroupLifecycleOps,GroupHitlCoordinatorandGroupConversationStoremerge cleanly, and the resolved merge compiles (main and tests) with #834's erasure and in-flight tests passing.Review refutation:
speaker_completefor a timed-out or failed parallel turn never carried raw exception text — the text sat in the transcript entry'serrorReason, and the event carried the (null)content. The newoutcomeflag makes the distinction explicit.
Config surface
All optional; absent means the defaults, and every value is clamped to its ceiling (non-positive = default):
Ceilings: 20 / 500 / 100 for the negotiation caps, 4000 for maxLessonChars, 32000 / 8000 for the stance input caps. Documented in group-conversations.md.
Compatibility
Stored group configs: new optional fields only; existing configs load unchanged and get the defaults.
REST:
POSTandDELETE /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}/conversationspages differently for non-admins (their own conversations only, in the query).SSE:
speaker_completegains the additiveoutcomefield and fires for timed-out parallel members.Internal API only:
IGroupWorkspaceStore.updateandGroupConversationService.startCadenceDiscussionAsyncare removed;casRunningDiscussiontakes one argument.
Files: RunningDiscussionWrites.java, GroupConversationService.java, GroupHitlCoordinator.java, GroupLifecycleOps.java, GroupConversationStore.java, GroupWorkspaceStore.java, IResourceStorage.java, MongoResourceStorage.java, PostgresResourceStorage.java, NegotiationEngine.java, RestGroupWorkspace.java, 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.unzipnow 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) andeddi.backup.import.max-uncompressed-bytes(256 MiB). An entry whose header understates its size is caught on the inflated bytes (tested). A breach throwsZipLimitExceededException, 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 infinallyas 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 newIResourceStorage.replaceHistory(MongoDBreplaceOne, PostgreSQLUPDATE). 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 aPUTlanding in between is a 409 rather than a 204 into history; andResourceSharingService.writeBackdoes the same and reports such a target as skipped instead of applied.M-P2 — regexes built from caller input. The descriptor listing pasted the
typequery parameter into an unanchored regex (ReDoS, 500s, or one pattern that selected every descriptor of every type);findByOriginIddid 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.escapeRegexCharsbecame public for this. The origin id is an equality filter, not a pattern: a newIResourceFilter.QueryFilter.exactbecomesFilters.eqon MongoDB and=on PostgreSQL. An escaped^…$regex was not enough — on MongoDB$also matches before a final newline, so an archive entry namedname\nwould be selected by a merge looking forname— and no single strict end anchor works on both backends (MongoDB's\zis rejected by PostgreSQL, whose\ZMongoDB 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.MongoResourceStoragenow answers "not found" for an id that cannot be an ObjectId inread,readHistory,readHistoryLatestandgetCurrentVersion, 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) assourceId, 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.storeHistoryAndRemovenow 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 throwsResourceModifiedExceptioneither 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 staledeletedflag 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.fileshad no index on the metadata the store filters on, so everystorageReflookup, upload quota check, listing and GDPR per-conversation delete was a full scan.GridFsIndexInitializer(aStartupEventobserver, MongoDB only) creates ascending indexes onmetadata.storageRef,metadata.conversationIdandmetadata.grantsat boot within one 10 s budget for the whole pass (each call gets what is left of it, sincewithTimeoutbounds a single operation, and a timeout skips the remaining indexes rather than waiting once more per index); none is unique (legacy blobs have nostorageRef) 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 inMongoResourceStorage(resource id, history row id, driver message), the index WARNs inGridFsAttachmentStoreandGridFsIndexInitializer, the per-document failure lines inMigrationManager, and the snippet-name lines inRestImportService's snippet import. Covered byMongoResourceStorageLogInjectionTestandMigrationManagerTest.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
sourceIdchanged from the local id to the archive id; the import accepts both.selectedResourcesnow also filters snippets on a create import and a first-time sync.IResourceStorage.storeHistoryAndRemove(history, id)becamestoreHistoryAndRemove(history, id, expectedVersion); internal SPI, both backends implement it.
Not done / follow-ups
LegacyDocumentMigrationsstill catches a failing transform itself and returnsnull("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, RestImportService.java, IResourceStorage.java, HistorizedResourceStore.java, ModifiableHistorizedResourceStore.java, MongoResourceStorage.java, PostgresResourceStorage.java, DescriptorStore.java, IResourceFilter.java, GridFsAttachmentStore.java, MigrationManager.java, RestDocumentDescriptorStore.java, ResourceSharingService.java, import-export-an-agent.md, 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 fieldcarryToolResultsOnEscalationdefaults totrue; set it tofalseto 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.
maxCostPerRunis checked against step token cost + judge token cost + tool cost (RunTotals.runCostUsd()).JudgeModelConfiggainsinputPricePer1M/outputPricePer1M, and negative values fail deployment inCascadeConfigValidator, the same rule as the other new price fields.ConfidenceEvaluator.EvaluationResultcarries the judge's token usage, including when the judge's reply was unusable and scoring fell back to the heuristic. Judge spend is included inrunCostUsd/cascadeCostUsd, and each step's trace entry getsjudgeTokenUsageandjudgeCostUsd.Carried calls always have ids.
ToolLoopRunner.toolExchangegives a null-id call (Ollama and Gemini bindings pass the provider's id through, often null) a syntheticgen-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 fromToolLoopRunner(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 ascarriedToolExchangeRejected, and counted aseddi.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.mdsays 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 fromhttp,mcpanda2asources are cached only when the task names the tool intoolCacheScopes. 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.namespacedScopeTagaddssrc:<source>andagent:<agentId>to the key. The agent is omitted only for a built-in on theglobalscope, whose definition is identical for every agent. This means a built-in on theuserorconversationscope (websearch, weather, …) is no longer shared between different agents of the same user, which intentionally lowers its hit rate. Pickglobalfor 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
modelmeter tag is capped at 100 distinct values per node (the rest sharemodel="other"), and names are cut to 128 characters. Spans keep the real name.L2 — langchain4j has no
ModelProvidervalue for Jlama or HuggingFace (verified against the 1.20.0 jars: neither overridesprovider()), so both reportedOTHER. The decorators now registerLlmTelemetryListener.forModelType(listener, modelType), which tagsproviderwith EDDI's model type whenever langchain4j reportsOTHERor 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 aseddi.llm.stream.timeouts{path=legacy|tool_loop}(added to the dashboard and todocs/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, CascadingModelExecutor.java, LlmTask.java, ConversationHistoryBuilder.java, ToolCacheService.java, LlmTelemetryListener.java, 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
inputFieldoutput 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 withoutsecretInput, so the backend neither masked it nor redacted it from the audit ledger. The streameddonesnapshot, 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.
SecretInputFieldmoved out of the panel intosecret-input-field.tsx. The drawer renders it and sends the value as a secret turn.Secrets came back on reload.
snapshotToMessagesrebuilt user bubbles from the step'sinput:initial, which holds the raw text of every turn. A turn is now shown as the mask when it answered aninputFieldthe previous reply asked for (every such field sends its answer as a secret turn), or when the backend marks it: the output's displayedinput, orinput:initialitself, 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'sinput, and the backend leavesinput:initialraw unless ascope:"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 maskedinputor scrubsinput: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
conversationIdstill 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 carriesloadingConversationId, set for the length of the read and cleared by the latest load when it settles, or by whatever replaced the transcript.useSendMessagerefuses a send while it is set, before anything is shown or sent, and toasts why (new keychat.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_PROGRESSnow re-reads it everyEXECUTING_TURN_POLL_MS(1.5 s) until it leaves that state, for at mostEXECUTING_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 skipsIN_PROGRESSconversations: 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
errorframes 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_restrictedandrestriction_status_unavailable. The optimistic message and placeholder are rolled back.awaiting_approvalshows the pause banner; the others toast why the message was not sent. The list is pinned by a test that reads the codes out ofRestAgentEngineStreaming.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 keychat.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.createLlmConfigwrotemodelName/apiKey, butHuggingFaceLanguageModelBuilderreadsmodelId/accessToken, so the agent deployed and then failed on its first turn. A dedicated branch writes the right names. Sub-agent credential inheritance also readsaccessToken.gemini-vertex: the builder reads
modelId, which is now written, and no key. The provider also needsprojectIdandlocation, and neither setup request can carry them. The agent wizard and operator activation therefore no longer offer it (isProvisionableBySetupinmodel-suggestions.ts). A Vertex agent is created with another provider and switched in the LLM editor. Setup itself (REST and MCP) now refusesgemini-vertexbefore anything is created or vaulted, with a message namingprojectId/locationand 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-vertexreopens 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 byfix/manager-api-contractand 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 theirreplaceMessagescan 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 frominput:initial, so the same secret leak exists there. The workforce and group wizard provider pickers still offergemini-vertex. AddingprojectId/locationto 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" wroterunOnHttpCode: [].PrePostUtilssubstitutes 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:runOnHttpCodeis 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, newrenamable-key-input.tsx). The key inputs were read-only, so an added LLM model parameter was stuck asparam<n>and an MCP argument asarg<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, soconstructorortoStringare 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 asString(v)and written back as strings:5became"5", an object"[object Object]".McpCallsTasktemplates 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 oftrue.Controls for fields the backend removed or never reads: "Parallel Tool Execution" and its timeout (removed from
LlmConfiguration.Task), the RAG "Injection" selects (removed fromKnowledgeBaseReference/ragDefaults), "Paragraph" / "Sentence" chunking (rewritten torecursiveon save — a storedparagraph/sentenceis 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/iterationObjectNameare read by nothing; batching ispreRequest.batchRequests). Stored values are left untouched in the JSON.sizematcher bounds (
rules-editor.tsx). The preset wrotemin:"",max:"", andSizeMatcher.setConfigsparses each withInteger.parseInt, so the save failed with a 400. The preset is now{ valuePath: "", min: "1" }. A clearedmin/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
nullHTTP callrequest(apicalls-editor.tsx), an output set withoutoutputsor a group withoutvalueAlternatives(output-editor.tsx), and a property setter withoutactions/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
PrePostUtilsunder their scope as-is, sosecretthere kept the value in plain text. It is no longer offered on those rows; a storedsecretstays visible (disabled option) with a warning. The property setter warns whenever asecretrow would not be vaulted: a "From path", or a value given asvalueObject/valueList/ number / boolean instead ofvalueString.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}withparseInt(v) || dwrote 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 storeundefined(the backend default, shown as the placeholder) or an explicit empty value. The whole text must be a number (1e3is 1000, and on leaving the field it shows1000); an integer field treats1.5as invalid instead of storing1.Monaco schema scope (
json-editor.tsx). One schema was registered withfileMatch: ["*"], 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 freshfetchVersionarrow 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 savediscardRemovedSourcesdeletes 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
secretproperty row that has both avalueStringand 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 Javaint, sinceInteger.parseIntneither trims nor accepts2147483648. 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 oldarg<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.onloadlanding before it ran still saw the clean, old-version guard and ingested.NumberInputkeeps text that is not a number ("-", "1.5" in an integer field) in the field instead of reportingemptyValuefor it — for an optional field that removed the key from the config, and the save path does not look ataria-invalid— and on blur restores the stored value. An empty field still reportsemptyValue.
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.DebouncedNumberInputinagent-config-sections.tsxhas the same append-after-clear pattern once its 600 ms commit fires; that file is owned byfix/manager-version-after-save, so it is left for that branch (the newNumberInputis 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, mcpcalls-editor.tsx, llm-editor.tsx, rag-editor.tsx, json-editor.tsx, version-diff-dialog.tsx, ingestion-sources-panel.tsx, ingestion-files-panel.tsx, number-input.tsx, renamable-key-input.tsx, 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, 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-emptydreamTypeorteamCadenceType. Such a schedule carries a "System" badge. An ordinary edit now also echoestenantId,allowSelfSchedulingand any free-formmetadata; before, a PUT without them reset each one to its Java default. The backend carry-over for the same fields belongs tofix/pipeline-scheduling.enabledwas 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 carriesenabledat all. The real fix is for the backend to stop takingenabledfrom a PUT, as it already does forfireStatus. That belongs tofix/pipeline-scheduling.persistentConversationIdis 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>oridx:<n>, so a numeric id cannot collide with an index. A FAILED schedule still in its backoff answered Retry with a 404. Dismiss is amarkCompleted, 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 isDEAD_LETTERED. Every other row shows the schedule's current status, or says the schedule no longer exists. FencingmarkCompletedon the server isfix/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 whatCronParserdoes. The preview had treated*/2as restricted and ORed the fields, so0 9 */2 * MONpreviewed every Monday while the server fires only on odd-numbered Mondays.
Logs (session-log-store.ts, use-logs.ts, logs.tsx, new 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.streamLogsreplays 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.
useHistoryLogsnow 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, pipeline-trace.tsx, cost-dashboard.tsx, use-debug-events.ts, new 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 (
resethad no caller). After a switch of conversation or agent, the previous conversation's turns stayed on screen and new ones were added to them.bindConversationis 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, againststepIndex, the conversation's own step number. So costs and models were attached to the wrong turn.resolveLiveTurnStepsnow 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 byid, 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). 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). 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 asconversationStepSize, so that value is used. The server-side search is now debounced (300 ms).Conversation monitoring (
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, newworkflow-steps.ts). "Add Task" appended new steps afterai.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).DELETE /administration/orphanshas 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). 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 throughgetErrorMessage, 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.tsis also in the API layer thatfix/manager-api-contractedits. This branch touches only the two cron helpers,parseCronanddayMatches.The locale files will conflict textually with every other Manager branch. The keys are appended at the ends of their namespaces.
fix/pipeline-schedulingowns the backend halves of the schedule fixes: carrying over metadata,tenantIdandallowSelfSchedulingon PUT, and fencing dismiss.
Not done here
The audit ledger has no tie-breaker in its sort (timestamp only on Mongo,
created_aton Postgres). The client de-duplicates by id, but a secondary sort onsequenceor_idbelongs 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
/rotatepath EDDI has never had and fell back to a PUT withoutallowedAgents, 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/rotateMSW 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): the backend'spauseIdwhen 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
hmacfield. It now callsGET /auditstore/verify/…and mirrors the report'sintact()/tamperingSuspected(): green only for verified, red for disproven signatures or a broken chain, amber for anything unproven (unsigned,UNKNOWN_KEYfrom #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 usemutateAsync(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
mainit could only answer "not supported". It is kept on the local branchfeat/manager-adopt-master-keyfor 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
uncoveredCountcompares. 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), neverplatformConfig; 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
mainand against #836 / #839:pauseIdis 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
mainan 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, secrets.tsx, approvals.tsx, audit.tsx, audit-verify.ts, share-dialog.tsx, channel-detail.tsx, channel-secrets.ts, 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); 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
SafeHttpClientconsulted 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 thesend*Boundedpair) now runs throughsendAsync(...).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. Thesend*Boundedpaths already bound the body with main'sBoundedBodyReader(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*Boundedpaths that was anInputStreamper hop that nothing ever saw or closed, holding the hop's connection. ForofStringit was a body buffered whole, however large. It now goes to a discarding subscriber that reads at most 64 KiB, or nothing when the declaredContent-Lengthis larger, then drops the connection and lets the redirect proceed. The caller's handler only ever sees the final response.sendNoRedirectstill 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) andWeatherToolwere the two callers #862 left on an unboundedofString. They now read through main'sSafeHttpClient.sendBounded, at 2 MiB and 1 MiB. A truncated answer is refused rather than parsed as a JSON fragment.WeatherToolno longer hands the operator's key to the model (H17).unitswent into the URL verbatim. A value with a space in it madeURI.createthrow 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.unitsis 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::/48is 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/48translator64:ff9b:1:7f00:0:100:808:808is 127.0.0.1, and it passed as 8.8.8.8.security.mdnow 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/sendValidatedBoundedandBoundedBodyReader, with the same property names (eddi.tools.web-scraper.max-response-bytes,eddi.tools.pdf-reader.max-download-bytes). This branch's handler-levelBoundedBodyHandlersis 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
HttpClientwith a bounded body read, instead of moving toSafeHttpClient.sendNoRedirect.Inline OpenAPI
$refinspection is main'srejectUnsafeInlineRefs, which also fails closed. Two of main's decisions differ from this branch's:http(s)references stay allowed, guarded bysetSafelyResolveURL, and a schema property literally named$refis refused. This branch'sMcpApiToolBuilderExternalRefTestis 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 resolvingfile:references, and local references still resolving. All of these pass on main unchanged.UrlValidationUtilsis main's. It adds Teredo unwrapping, and makes two decisions that override this branch's:198.18.0.0/15is blocked, and of240.0.0.0/4only255.255.255.255is. 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 translated169.254.169.254is 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.WebSearchToolURL-encodes the Google API key andcx. A failure message that names the request URI has itskey=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.computeNextFirewalked real time minute by minute. So30 2 * * *inEurope/Berlinfired 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.mdand 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,3still 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, tests inCronParserTest(DST section).
M-Q2:
capabilityMatchtemplating never ran.resolveTemplateonly 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.mdno 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 (sayc{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.batchRequestssent 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<= 0means the deployment default (100). Saving a value above the deployment ceiling (1000) is refused with 400 byRestApiCallsStore, 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-sizeandeddi.httpcalls.batch.max-size-ceiling, inconfiguration-reference.mdandapplication.properties. Raising the default restores the old behaviour for a deployment without editing stored configs. The keys sit undereddi.httpcalls.*next to the existing httpcalls knobs. The review suggestededdi.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,BatchRequestBuildingInstruction.java,httpcalls.md, testApiCallExecutorBatchCapTest.
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.
findDueSchedulesnow orders bynextFire, 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.
carryOverNonEditableFieldsnow also carries overmetadata,tenantIdandallowSelfScheduling, but only when the body omits them.metadataselects 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": nulland{}both clear it."Absent" is tracked by a non-serialized marker set by the setter, for
metadataand forallowSelfScheduling, which is a primitive.tenantIdhas 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
fireNowand the ingestion endpoints enforce.updateSchedulenow judges the stored row:RAG ingestion: refused for everyone with 409. The row is minted from the knowledge base's ingestion source, whose
cronis 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
addCadenceapplies. 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
buildConfigfix belongs tofix/manager-ops-pages.
Dead-letter dismiss could reset a live claim.
dismissDeadLettercalled the unfencedmarkCompleted, 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, andlastFiredmoved although nothing had fired.New
IScheduleStore.dismissDeadLetter(id, nextFire), implemented on Mongo and Postgres. The write is conditional onfireStatus = DEAD_LETTEREDand leaveslastFiredalone. It also leavesenabledalone when there is a next fire: dead-lettering never clearsenabled, so a disabled dead-lettered row was disabled on purpose (by/disableor an agent undeploy) and stays disabled until/enable. The API description anddocs/scheduling.mdnow say so, and tests in both stores pin it.The contract declares it:
@APIResponse(409)onIRestScheduleStore.dismissDeadLetter. The Manager still offers "Dismiss" on failed fire logs and shows a generic toast on 409. That fix is a follow-up forfix/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 throughLogCaptureSupport.
G1: sync could never recover a lagging descriptor.
bumpDescriptorread 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
updatedonly 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 asupdatedas 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
createdonly when the store accepted it (201) and its descriptor was written. Before, it could appear under bothcreatedandfailures.
Compatibility
Stored configs: new optional field
maxBatchSize. A batch config whose array grows past 100 now fails the turn instead of sending everything; raisemaxBatchSize(up to 1000) if that is intended.REST:
POST /schedulestore/schedules/{id}/dismissanswers 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) andeddi.httpcalls.batch.max-size-ceiling(1000).REST, httpcalls: create/update/duplicate of an apicalls config whose
maxBatchSizeexceeds the ceiling answers 400.PUT /schedulestore/schedules/{id}: omittedmetadata,tenantIdandallowSelfSchedulingare kept instead of being nulled or reset to false.
Coordination
ScheduleFireExecutoris untouched.fix/conversation-turn-stateedits its rollover logic.Authorization: one check was added — the stored-row guard on
updateSchedulefor 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 withfix/workspace-authz-scoping, which should expect this guard inupdateSchedule.
🐛 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.
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
tombstoneAfterMissedRunsruns every page below it lost its vectors. Validators are now sent only for pages atmaxDepth, 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.xmlwhen 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 nexttombstoneAfterMissedRunscomplete runs. Sitemap pages are queued at depth 0, so a source that stayed undermaxPagesbymaxDepthalone 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.processScheduledFirenow claims the run and starts it on its own worker (newIngestionReport.Outcome.STARTED), as a manual run does. When that run later fails, the worker writes a second,FAILEDfire-log entry for the fire (it does not raise the schedule'sfailCount, so a failing crawl neither retries early nor dead-letters — documented inscheduling.md). On a graceful shutdown the runs this instance was carrying are closed asCANCELLEDand 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 bysyncSchedulesfor 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.
discardRemovedSourcesskipped 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.
purgetakes the maintenance claim and returnsfalse(→ 409) while a run holds the source, instead of a check-then-act in the REST layer. The rename path uses the newforgetStateAfterRename, 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 missedtombstoneAfterMissedRunstimes, a failure-free run removes every chunk of the source not written by a run since the purge (sweepOrphans, filterrunId NOT INthe 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.
forgetDocumentnow returnsForgetOutcome(REMOVED/UNSUPPORTED/FAILED). OnFAILEDthe file is kept and the delete answers503(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, whichlistRunsomits 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 throughStatus.parse, which maps one this build does not know toFAILED. Rolling upgrade: a node built before this branch still throws onMAINTENANCEinlistRuns(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
streamkeyword, non-Flate filters (ASCIIHex/ASCII85/RunLength/LZW, alone or chained), one stream referenced many times from a page's/Contents(an OOM at-Xmx1gfrom a 61 KB file), and encrypted streams. PDFBox'sMemoryUsageSettingcannot close these:Filter.decodewrites into a buffer it sizes itself and never consults the stream cache (checked in the 3.0.8 bytecode). SoPdfDecodeBudgetwraps every filter in PDFBox'sFilterFactory, 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 pastExtractionLimits.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.PdfStreamBudgetstays 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,.xlsxor.pptxname 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
RequestBodyLimitGuardholds every other endpoint toeddi.http.limits.default-max-body-size(default25M), raised automatically to fit an attachment ateddi.attachments.max-size-bytesbase64-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 (noContent-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-bodiesturns that off. An HTTP/2 request without acontent-lengthis left to the global ceiling: a filter cannot tell it from a request with no body. HTTP/1.x refusals sendConnection: close; HTTP/2 ones do not, since HTTP/2 forbids the header. The upload exemption is matched belowquarkus.http.root-path. A ZIP import gets no exemption: it is held to the same effective limit as every other endpoint (25Mby default, raised to fit the attachment allowance, capped atquarkus.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 (writePageon the glyphs gathered beforePageTooLong) and stops there.One dead link no longer blocks the orphan sweep for good.
sweepOrphanswaited for a run with no failure, andfailedcounts 404/410s and files with no text — definitive answers after which no old chunk needs keeping. It now waits on the newCollector.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.
cleanUpAfterRunhas its own guard in the worker'sfinally, soonFinished— and with it a scheduled run'sFAILEDfire-log entry — always runs.RequestBodyLimitGuardbroke 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 — withConnection: 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-bytesraised the guard's limit pastquarkus.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.RequestBodyLimitGuardnow 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 inapplication.properties,configuration-reference.mdandattachments-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.
cleanUpAfterRunthen found its purge refused (the new run holds the source) and ignored that. It now calls the newIIngestionStateStore.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 tofix/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
503delete message or theSTARTEDfire 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
FilterFactoryby reflection. A PDFBox upgrade that renames that field makes every PDF refused (loudly, with an ERROR at first use), never read unbounded;DocumentExtractorsTestfails 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 acontent-lengthis 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;
cleanUpAfterRunremoves it once the run ends.
Files: WebCrawler.java, IngestionPipeline.java, RagSourceIngestionService.java, IIngestionStateStore.java and its Mongo/PostgreSQL stores, IngestedFileService.java, PdfTextExtractor.java, PdfStreamBudget.java, PdfDecodeBudget.java, DocumentExtractors.java, RestRagIngestion.java, RestRagStore.java, RequestBodyLimitGuard.java, 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: newAgentConfiguration.dynamicOrigin(createdByAgentId,createdInConversationId,createdInGroupConversationId,createdForUserId), stamped on v1 by a new internal overloadAgentSetupService.setupAgent(request, origin)— not aSetupAgentRequestfield, which is a REST/MCP body.TeardownAgentToolrefuses, 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.cleanupEphemeralAgentsapplies 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.updateAgentkeeps the stored value whatever the body says (a store failure on that read fails the update), andduplicateAgentand 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 viaCallerIdentity.isAdminActingAs, from the newCallerIdentity.admin).recruit_agentasks it for the discussion owner before the deployment check, so a refusal never reveals whether an off-limits agent is deployed.converse_with_agentasks 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 matchingdynamicOrigin.M-T2 — retain lasted one turn. The retained set is seeded from the latest recorded set (not the union, so
unretain_agentis not resurrected), narrowed to agents still tracked as created.Reserved keys read exactly (CWE-863, CodeRabbit on this PR).
IConversationStep.getLatestData/IConversationStepStack.getAllLatestDatamatch by prefix, so a client key such asgroupIdSuffixordynamicAgentConfigX— not reserved by name — was read as the reserved key. Every reserved-key reader now usesgetDataor the newIConversationStepStack.getExactDataPerStep(ConversationGroups,DynamicAgentToolsProviderpolicy/created-list/delegation-depth,AgentOrchestrator.groupConversationIdOf), andClientContextGuardalso drops any key that starts with a reserved name (shadowsReserved). Keys extending an operator-permitted reserved key stay permitted. Documented inpassing-context-information.md.C3c — an earlier step's
groupIdis verified.ConversationGroups.resolveGroupIdstrusts the current step'sgroupId, but an earlier step's only when that step'sgroupConversationIdnames a running discussion that has this conversation as a member and belongs to that group (LiveDiscussionRegistry.isLiveMember). Applied on all three paths — theusermemorytool, thelongTermwrite boundary (IPropertiesHandler.getGroupMembershipCheck) and undo/redo sync — so agroupIdforged 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:
dynamicOriginis additive (strict-boundary round-trip test passes). Sub-agents created before this change carry no marker:teardown_agentrefuses them and end-of-discussion cleanup only undeploys them.CallerIdentitygained a trailingadmincomponent; 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 asself.
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. Main's vaulting and step-scrub code moved there fromPropertySetterTaskunchanged, 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
fromObjectPathbranch 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
PrePostUtilsthat failure isSecretPropertyVault.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. It runs from thevalidatehook ofPropertySetterStore,ApiCallsStore,McpCallsStoreandLlmStore, 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.ChatModelRegistryresolves${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.requireConfiguredParametersapplies 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.
systemMessageandpromptare never resolved, so they are exempt.The false comment at
LlmTask.escapeConfigReferenceMentionsis 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 andSet-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: secretinstruction is also removed from the map returned as the LLM tool result (review #6).PrePostUtils.runPostResponsenow returns the plaintexts it vaulted.
S5 —
URI.createmessage leak.The request path is resolved before it is parsed, and
URI.createquotes the whole string it rejects. A secret in a URL path therefore reached the ERROR log and the task-error digest.buildRequestandexecute'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": 8080would otherwise blank every equal value of the turn, loadedlongTermproperties 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.vaultApiKeyused to fall back to plaintext when the vault was configured but the write failed. It now fails the setup.A plaintext
apiAuthincreateApiAgentis 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 equal4711and the exact match missed it. With exact-match values in play,SecretValueScrubbernow 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.
buildRequestnow redacts aTemplateEngineExceptionas well as aRuntimeException. 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.
conversationIdOfalso keeps its segment match: the description, which onlyvaultwrites, already names the conversation.
Behaviour changes to know about
Default install (vault disabled): every
scope: "secret"instruction fails its turn with an error namingEDDI_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 setsvalueObject/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
LlmTaskgoes through the samerunTemplateEngineOnParams, 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 aspauseIdby conversation and groupapproval-status(REST and MCP).ConversationHitlService.resumeConversationrefuses 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, REST409, MCPerrorCode: PAUSE_CHANGED. The pre-CAS comparison runs only while the conversation isAWAITING_HUMAN: an already-resolved conversation gets the ordinary state conflict (409naming the state, MCPWRONG_STATE).GroupHitlCoordinator.resumeDiscussionchecks it againstpausedAtand throwsIGroupConversationService.GroupPauseMismatchException(aGroupDiscussionException, so REST still answers409); the MCPapprove_group_phasetool takes an optionalpauseIdargument likeresume_conversationand answersPAUSE_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 separateCallerIdentityContextapprover slot thatToolLoopResumerpromotes 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.
sanitizePendingToolCallsForApprovernow nullsPendingToolCallBatch.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:
pauseIdon the decision body, theapproval-statussummaries and as an optional argument of the MCPresume_conversationandapprove_group_phasetools (an additive MCP tool-schema change). OmittingpauseIdkeeps 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 exposespausedAt— no migration. It is the same valuemain's Slack approval records use as their pause epoch.
Files: ConversationHitlService.java, CallerIdentityContext.java, ToolLoopResumer.java, GroupHitlCoordinator.java, HitlDecision.java, McpHitlTools.java, ConversationMemoryUtilities.java; docs hitl.md, 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.submitHumanInputbinds 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
pauseIdfromapproval-statusinto 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.
VaultSaltManagercreated 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 withISecretPersistence.putMetaValueIfAbsent(MongofindOneAndUpdate+$setOnInsert, PostgresON 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 nowinsertDek; the loser reads back and uses the winner's key.H6c — KEK rotation.
rotateKekis 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 iseddi.audit.hmac-keyif 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 v5v5:<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 statusUNKNOWN_KEY(counted withinvalid, listed separately in the problem list) instead of as tampering. Retired keys go ineddi.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 unsaltedsha256. 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 fromGdprComplianceService— 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 throughseal()are not bound yet — follow-up, needs a row identity inseal(). System values are bound to their name (see M1 below).L-S3 — tenant reset.
SealedDataRotationParticipant.discardAllis called before the DEKs are deleted;ConnectionGrantResealerdeletes 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-systemcannot 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 …/grantaccepts optionalexpectedAllowedAgents; 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.checkReferencesreturnsREFERENCES/DOES_NOT_REFERENCE/UNKNOWN; an unreadable agent, workflow, extension config or connection makes the impact reportcomplete=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-systemtenant and everysystem-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 reportsUNKNOWN_KEYonly for a recorded id, andINVALIDotherwise. The docs and Javadoc no longer call it "distinguishable from tampering".Later rotation after an interrupted legacy migration (review m1).
rotateKekalso 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 inreconcileKekCheckis 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/$setEqualsreplaces$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).
AuditKeyringuses a volatile fast path instead of a synchronized call per entry.
Review follow-ups (CodeRabbit)
Refused legacy-salt rotation.
rotateKekreserved the pending salt before verifying, so a refusal (a wrongoldMasterKey, 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 fromsigningKey()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, makecheckReferencesanswerUNKNOWNrather thanDOES_NOT_REFERENCE. The deploy gate (findUngrantedReferences) is unchanged.Interrupted adoption.
adoptCurrentMasterKeyalso 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.
dekPersistenceFailurenow failsinsertDek(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:
GrantRequestgains an optional field;PUT …/grantcan answer 409 only when that field is sent. Audit verification can reportUNKNOWN_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, VaultSecretProvider.java, EnvelopeCrypto.java, AuditKeyring.java, AuditHmac.java, VaultGrantChecker.java, secrets-vault.md, 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 MCPdiscuss_with_group,start_group_discussionandcontinue_group_discussiontools now callrequireUseAccess(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
requireOwnUserIdalready 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 (nouserIdorsystem: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'sScheduleOwnerScope, refined; both backends) for the same paging reason the HITL redaction lives there.readSchedulenow hides HITL timeouts from non-admins like the listing does, and fire logs are visible exactly when their schedule is.fireNowrethrowsForbiddenExceptioninstead 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.fireNowon a cadence needs EDIT on its group, andTeamCadenceService.processScheduledFirenow takes the firing schedule's id and refuses unless it is theCadence.scheduleRefthe 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_taskneeds EDIT andlist_team_backlogVIEW on the group, matchingRestGroupWorkspace.H2f — parser.
POST /parser/{id}declareseddi-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 MCPlist_agentsanddiscover_agents, return only agents the caller may USE and run every descriptor throughredactUnlessOwner, so grant lists and access indexes no longer leave the server to viewers. The channel router, which must see every agent, reads the new unscopedIDeploymentStatusReader.H2h —
/v1name oracle.AgentModelResolver.resolveadmits 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 MCPget_visible_memoriestool require USE on every named group before recalling (ResourceAccessGuard.requireUseAccessToEach). The context-key path is branchfix/reserved-context-keys.A1 — anonymous agent-card fan-out.
getDefaultAgentCard/listA2AAgentsreuse their roster scan for 30 s (empty result included), so a flood of anonymous/.well-known/agent.jsonrequests costs one scan per window instead of ~200 queries each.A3. The agent-card WARN log sanitizes the anonymous path parameter.
E5 —
SelfUrlResolverignoredquarkus.http.host. With the listener bound to one specific non-loopback address, the derived self URL is that address instead of127.0.0.1, which might be another process and would have received${caller:token}'s self release. Wildcard and localhost binds keep127.0.0.1; a specific loopback or IPv6 address is used verbatim.
Pre-push review follow-ups
Failed-fires view.
GET /schedulestore/schedules/admin/failedstays 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 thanlimit, 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 MCPchat_managedno 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./v1model-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.
ZipArchivedeletes 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
continueDiscussionStreaminghas for its ownership check (the review suggested the opposite direction; the existing continuation test documents the 403 contract).ResourceAccessGuard.currentLevelkeeps VIEW for a resource with no descriptor:VIEW.includes(USE)holds, so it already agrees withrequireLegacyFallback. A test now pinshasAccess(USE)for it.Testcontainers cases for the scoped listing in
datastore/mongo/MongoScheduleStoreTestanddatastore/postgres/PostgresScheduleStoreTest, plus docs inscheduling.md,hitl.md,import-export-an-agent.mdand AGENTS.md.
Design decisions
ResourceAccessGuardgainedcurrentLevel/hasAccess(non-throwing, current descriptor) for id-only listings, andrequireUseAccessToEach.Schedule listing under enforcement without an
agentIdshows 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) gainedsharedOnlyIfCreatedByCaller()/withTeamCadences(); its two-argument form keeps main's meaning.TeamCadenceService.processScheduledFire(Map)becameprocessScheduledFire(String, Map).Export download URLs changed shape (see H2c).
RemoteApiResourceSourcefollows theLocationheader and is unaffected.Non-admin editors no longer see or manage other users' personal schedules even with workspaces off.
/parsernow needs an authoring role.
Follow-ups
RestInterfaceFactorystill hard-codes127.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
isAdminis true, so nothing changes there.requireExistingConversationOwnerresolves 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:deleteif 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
sayandsayStreamingentry 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 inconfiguration-reference.mdandgdpr-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_managedbefore 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./endreads 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-endonly if there is no named caller). The response body listsended,skippedandfailed, 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 rawsetConversationState(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
/activeand/endare 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, RestConversationStore.java, IRestConversationStore.java, ConversationService.java, RestAgentEngine.java, RestToolHistory.java, McpConversationTools.java, RestAgentManagement.java, RestAgentAdministration.java, gdpr-compliance.md, 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.
IConnectionGrantStoregainsfindAllByPrincipalanddeleteAllByPrincipal, which span every tenant, like the rest of the cascade. They are implemented in Mongo (plus a newidx_grant_principalindex) and Postgres (plus a newidx_cg_principal).The cascade adds a
connectionGrantsstep, andGdprDeletionResultaddsconnectionGrantsDeleted, which also appears in the MCPdelete_user_datapayload.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 likeSealedDataRotationParticipant. The cascade calls it first, as step 0.ConversationServicecancels 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.GroupConversationServicesendsCANCEL_IMMEDIATEto the user's running discussions. If a participant throws, the step is recorded as failed (inFlightConversations/runningGroupDiscussions).UserMemoryToolrefuses writes once its turn is cancelled (ContextualToolsProviderpassesmemory::isCancelled).GroupConversationStore.updateno longer creates the document. It replaces an existing row viastoreIfCurrentVersion(SINGLE_VERSION)and throwsGroupConversationGoneExceptionwhen 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.upsertandmergePropertiesrefuse reserved keys in both backends, withReservedMemoryKeyException(anIllegalArgumentException). The only write path for them is the newupsertReserved, used byGdprComplianceService.isProcessingRestrictedcounts only rows in thegdprcategory, and considers every one of them.unrestrictProcessingdeletes every row under the key.UserMemoryTool(rememberFact/forgetFact),RestUserMemoryStore(upsert/delete → 400),RestPropertiesStore.mergeProperties(400) andMcpMemoryToolsrefuse reserved keys. The delete-all surfaces now usedeleteAllExceptReserved, and the store'sdeletePropertieskeeps reserved rows. The MCPdelete_all_user_memoriesdescription 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
deletePropertiesnow escape the LIKE underscores. The old'_gdpr_%'also matched keys such asagdpr1, 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.
executeDiscussionhandlesGroupConversationGoneException, also when it arrives wrapped, from any write inside the leg. The R2 cancel branch tolerates a document that is already gone. The listener getsonCancelledrather thanonGroupError, and the leg no longer logs an ERROR, bumps the failure metric or throws a 5xx. Previously the secondupdate()inside the catch threw Gone again and left SSE streams hanging.GroupLifecycleOps.failConversationhas a corrected comment.Late audit entries. A cancelled turn still flushes its audit buffer while it unwinds.
AuditLedgerService.markUserErasedmakes 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 stepoauthStates, before the grants are deleted. A late callback can no longer mint a grant for an erased principal.Signalling continues past errors.
GroupConversationService.stopInFlightWorkkeeps 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 onlyrestrictProcessingwrites.__service__is refused. Erasure and export reject this system principal: 400 on REST, an error on MCP, and anIllegalArgumentExceptionin the service.LIKE escape character. The Postgres LIKE escape is now
!, which does not depend onstandard_conforming_strings.Access check before key check. REST
upsertMemoryruns the ownership check before the reserved-key 400.Docs. Stale "GDPR delete-all" wording in
IRestUserMemoryStore(OpenAPI),docs/user-memory.mdanddocs/mcp-server.mdis fixed, and the step numbering indocs/gdpr-compliance.mdis 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
oauthStatesand stores its grant after stepconnectionGrants, a window the length of one token exchange.
CodeQL follow-up
Two
java/sensitive-logalerts inGroupHitlCoordinator.cancelDiscussion. The erasure sweep (GroupConversationService.stopInFlightWork) walks the keys of the map of running discussions, then namedactiveTokens. 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 incancelDiscussionthat 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 renameddiscussionControlsin 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 throughLogSanitizer, like the rest of the class. No behaviour changes.McpMemoryToolsBranchCoverageTeststill expected the old delete. Two of its cases stubbedcountEntriesand verifieddeleteAllForUser, whichdelete_all_user_memoriesno longer calls since it switched todeleteAllExceptReserved. They now stub and verify that method, and assert thatdeleteAllForUseris never called from this surface.
Compatibility
REST/MCP shapes only gain fields:
connectionGrantsDeleted, andconnectionGrantsin 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
gdprcategory 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|redoanswer an empty 200 (moved) or 409 (nothing to move); the client typed them as returning a snapshot, so the hook threw onundefinedafter the server had undone the step and every further click undid another turn.undoConversation/redoConversationnow resolvetrue/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 (streamingdoneand non-streaming) from the backend'sundoAvailable, with asteps > 1fallback (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 * 50asindex; the backend'sindexis a page index (skip = index * limit). NowallPages.length. The sync page and the import dialog's target pickers only ever loaded the first page, so they now useuseAllAgentDescriptors, 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 noversionparameter. NewgetDescriptorVersions(id, latest)reads/descriptorstore/descriptors/{id}?version=vper 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 touserId: "manager-user", whichOwnershipValidatorrejects 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, notPropertywrappers, 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; newauditToolCalls()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 istrue, so it only ever showed the last step). Test:conversations.test.ts.Validation errors.
extractErrorMessagenow reads Quarkus's Bean Validation report (violations[].message, plus the RESTEasy Classic shapes), so@Valid400s on group saves / discuss / attachment limits show the constraint message instead of "Bad Request". Test:api-client-response.test.ts.getToolHistoryreturns aToolExecutionTraceobject (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.
useDeploymentStatusesand the chat picker now consultGET /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, flaggeddeployedVersion; the card's chip showsvNand 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 forPOST /backup/import. 48 shadowed duplicate handlers were deleted (a second schedule store, coordinator, audit, quota, currentversion, descriptor and tool-metrics copies, theagents/:idmemory-inspector copy, the factory/descriptorscopy). Four of the removed copies were the ones that actually answered, so their routes now answer from thebackupSyncHandlerscopy instead:POST /backup/export/:agentId(Locationagent-export.zip, wastest-agent-1.zip),GET /backup/export/:filename,POST /backup/import/preview(the currentImportPreviewshape, was a pre-6.x one) andPOST /backup/import(201 withimported-<timestamp>, was 200 with a fixedimported-agent; a merge still lands onagent1v2).openapi-contract.test.ts'sKNOWN_DUPLICATE_ROUTESratchet shrinks from 58 entries to the 10 deliberate:idfall-through layers.
Review follow-up
Agent detail Environments panel now uses
isLiveAtRequestedVersion: an environment live at an older version showsvNand 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.getDescriptorVersionsskips 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.
withAnyDeployedVersionnow 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 hiddenenv-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").auditToolCallsattachestool_errorreasons (budget, quota, HITL cap) to the refused call, or lists an orphan refusal. Test:audit.test.ts.useAllAgentDescriptorshas 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:
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 eslinton the file andnpx 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). 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 throwsCascadeReferenceErrorwith 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 aretryContext. When only the agent hop failed, the new optionalCascadeContext.agentWorkflowVersionrecords 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 passesagentWorkflowVersionfor 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,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 throughonCascadeContextChange. 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).useUpdateGroupnow resolves with the version the save created and seeds the cache under it. The inline HITL, phase and advanced editors report that version throughGroupConfigPanel.onVersionChange, and the page writes it to the URL.deleteGroupWithMembersdeletes the group first, so a refused group delete no longer leaves every member agent soft-deleted.Workforce settings (
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). 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, andisPendingcounts 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.useUpdateAgentseeds 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). A deploymentERRORstops 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.
loadParentsalso reads/currentversionfor the workflow and the agent. If either is newer than the version the page addresses (changed in another tab, say), the cascade throwsCascadeReferenceError(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.
CascadeReferenceErrorcarries acodeandparams.describeSaveError(src/lib/save-error.ts) renders them from the newcascadeSave.*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).
useCascadeSaveseeds 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.
useCascadeSavealso invalidates on aCascadeSaveError, so the picker offers the version the page moved onto.Workforce settings: the post-save guard waits for the URL once (an
awaitingUrlflag), 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, 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/currentversionis a follow-up.useAgentVersions(the agent-detail version list) belongs tofix/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_starthas 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;abortStreamkeeps 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 clearsinterrupted, so the "connection lost, keeps updating" notice does not outlive it."Stop and start new" confirmed before
group_startnamed 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
cancelErrorfield the board clears once shown — and no longer through the stream'serror, 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
cancelStreamcancels 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 keyWorkforce.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 keyWorkforce.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_completenow 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
outcomefield. Against currentmainthe backend sends no completion and stores the failure as an unattributed row, so the live placeholder closes as SKIPPED onphase_completeand 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_completestate REJECTED and never sendshitl_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
styleto 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_TEMPLATEmirrorsTEMPLATE_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 (repairNegotiationArbitrationinnormalizeGroupConfig): 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:
normalizeGroupTaskConfigrebuilt 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
retroConfigmeans 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, somaxLessonCharssurvives.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)
groupSaveProblemsmirrorsAgentGroupStore'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. TheironErrorshows 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 0with 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 honoursindex/limitas 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)
RestGroupConversationclose 409 text,IRestGroupConversationclose 409 description and the MCPstart_group_discussiondescription 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'sextractErrorMessagestill does not parse Quarkusviolations[]bodies (not in this item).
Files: use-group-discussion-stream.ts, workforce-board.tsx, workforce-thread.tsx, use-discussion-digest.ts, group-config.ts, hitl-config.ts, 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}andPOST /conversationstore/conversations/endwere reachable by any authenticated token. The same gap was closed independently onmainby 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 addsSecurityAccessControlAnnotationsTest, which pins the role set by reflection.converse_with_agentcross-user access. The tool now requires a model-suppliedconversationIdto 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-suppliedpersistentConversationId(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 checkcreate/update/firealready 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, solimit/offsetcount 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/updateare 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.UserMemoryToolvisibility guardrail. The tool now applies the configureddefaultVisibility, restricts writable visibilities to a configurable allow-set (defaultself), and refuses overwriting aglobalkey 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 newguardrailsfields, documented indocs/user-memory.md:allowedVisibilities,allowGlobalKeyOverwrite.returnDetailedstep-data exposure. The detailed conversion now drops sensitive internal keys (audit:*,*:trace:*,*Error) and runs values throughSecretRedactionFilter, matching the SSE path; full-fidelity debugging remains on the owner/admin-gated raw endpoint. Redaction recurses intoMapkeys and values, collection elements andObject[]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 requireseddi-admin/eddi-editorand aVIEWcheck 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-userand up) rather than mere authentication, and the reply returns only the text output instead of the serializedConversationOutputmap.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 onmain(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, ConversationMemoryUtilities.java, RestScheduleStore.java, UserMemoryTool.java, 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
approvalNotifiedbookkeeping instead of adding a second marker.UNKNOWN_PAUSErecords (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
IUserMemoryStoreProperties blob (readProperties) are migrated.
Files: ISlackApprovalRecordStore.java, MongoSlackApprovalRecordStore.java, PostgresSlackApprovalRecordStore.java, SlackInteractivityHandler.java, SlackEventHandler.java, SlackGroupDiscussionListener.java, SlackUserIdentity.java, RestSlackWebhook.java, ChannelTargetRouter.java, GroupConversationEventSink.java, DataStoreProducers.java. Docs: hitl.md, 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, OpenAiAuthFilter.java, 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
/v1shared-key caller setsX-OpenWebUI-User-Id: <victim>;rawIdis then the fully attacker-chosen bare<victim>, which is exactly where OIDC principals keep long-term memories. The standalone "brand-new chat inherits" move calledgetAllEntries(<victim>), upserted the result to the attacker'sopenwebui:<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.IUserMemoryStoreis retained (unused for migration) so the no-migration invariant stays enforced by tests.B (Should-fix) — same class in Slack.
SlackEventHandler.migrateLegacyMemoriesmoved all bare-Slack-id entries keyed only on the raw id, andteam_idis attacker-supplied in a validly-signed event, so a second integration's operator could pull a victim's legacy bare-id memories into their ownslack:<their-team>:<user>namespace. Fixed by removing the standalone move (adopt/rekey keeps the raw-id owner, no move needed) and by pinningteam_idto the signing integration where the webhook resolves it (RestSlackWebhook.verifyOriginreads the owner/signer integration'splatformConfig.teamId; the payloadteam_idis 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
getIntegrationByNamemight resolve to the attacker while the approval record belonged to the victim. Fixed fail-closed:ChannelTargetRouter.getIntegrationByNamenow refuses (returns empty) when more than one integration matches a name, andRestChannelIntegrationStoreenforces 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-
pausedAtargument threaded intoresumeConversation/resumeDiscussionand a CAS there, which is disproportionate here. Noted inSlackInteractivityHandler.
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, SlackEventHandler.java, RestSlackWebhook.java, ChannelTargetRouter.java, 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_idor eventteamdisagrees with the signing/owning integration's declaredteamIdis now rejected (403).The workspace comes only from the webhook-pinned
origin.teamId(); theevent.get("team")fallback inslackUseris removed.An unowned channel whose signing integration declares no
teamIdis treated as unbindable: the payload team is not trusted and the identity is team-less (slack:<user>), which reaches no victim'sslack:<team>:<user>. (An owned channel with no declaredteamIdwas first left trusting its payloadteam_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 declaredplatformConfig.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 forgeteam_id+userand reach anyslack:<team>:<user>— including users of integrations that do declare ateamId. The workspace now comes only from the declaredteamId, for owned channels and DMs alike; without one the identity is team-less (slack:<user>). Residual, documented: all team-less integrations share that namespace, soteamIdshould be declared on every integration of a multi-workspace deployment (docs/slack-integration.md, which now listsplatformConfig.teamId). TestRestSlackWebhookTest.ownedChannelOnTeamlessIntegrationIgnoresPayloadTeam, mutation-checked.Legacy
/v1mapping adoption could hand over an OIDC user's chat. Inhttp-policy=authenticatedthe bridge writes mappings under the bare OIDC principal, with the same intent. After a switch to shared-key mode, a caller sendingX-OpenWebUI-User-Id: <principal>(and a matching chat key, which falls back todefault) 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(defaultfalse). Upgrading Open WebUI deployments whose/v1never 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. TestOpenAiConversationBridgeTest.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) andrekeyRace_dropsTheLegacyMappingOnceTheSameConversationIsConfirmed.OIDC principals could carry the
openwebui:prefix. An OIDC user namedopenwebui:alicewas the same EDDI identity as the shared-key caller sendingalice.OpenAiAuthFilternow refuses (401) an OIDC principal carrying the reserved prefix. TestOpenAiAuthFilterTest.oidcMode_refusesAPrincipalCarryingTheReservedOpenWebUiPrefix, mutation-checked.Duplicate-name derivation scanned every integration per candidate. It now reads the used names once;
validateUniqueNamestays 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 bothopenwebui:<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: MongoDBcardIdfield, PostgreSQLcard_idcolumn (added withADD COLUMN IF NOT EXISTSto a table from an earlier build of this branch). TestsSlackInteractivityHandlerTest.staleCardOfSameConversation_…,staleCardOfSameGroup_…,unboundLegacyValue_isRefused,unknownCardId_isRefused(mutation-checked), plusPostgresSlackApprovalRecordStoreUnitTestand card-id round-trips inMongoSlackApprovalRecordStoreTest. 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, conversations.ts, snapshot.ts, 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, 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.
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.
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.
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, 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.
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.ymlanddocker-compose.postgres-only.ymlnow publish EDDI on127.0.0.1by default with anEDDI_BINDoverride; exposing an unauthenticated instance off-box is now an explicit choice. Add-on overlays (chroma,ollama,nats,monitoring,openwebui) bind their published ports to127.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.demonow runs as a non-root user.GCP provisioner (
gcp/provision-vm.sh): firewall rules are scoped to the caller's detected IP by default (--source-rangesto override,--i-understand-publicto open to0.0.0.0/0);createrefuses an open-access VM unless--with-author--i-understand-publicis given; a randomKC_BOOTSTRAP_ADMIN_PASSWORDis generated per VM (printed once) instead ofadmin/admin; the public Keycloak vhost blocks/admin; the success banner no longer prints staleeddi/eddicredentials.Installer (
install.sh):.env/.eddi-configcreated mode600before the vault key is written;EDDI_BRANCHderives from a pinnedEDDI_VERSION(release tag) rather than alwaysmain, in the installer and the generatededdi updateCLI; auth wizard clarifies the localhost-only default.CI/CD (
.github/workflows/ci.yml): a release tag build now fails unless the tagged commit is an ancestor oforigin/main; cosign signs the pushed image digest rather than a tag; the cosign identity regex is tightened fromtags/.+to the release-tag pattern (also in NOTES.txt,docs/release-signing.md,docs/build-reproducibility.md)..github/workflows/auto-approve-copilot.ymlpins the auto-approval to the reviewed commit (commit_id) and skips PRs that touch.github/**.Helm (
helm/eddi): the chart refuses to render wheningress.enabled && !oidc.enabledunlesseddi.security.allowUnauthenticatedis set explicitly;EDDI_MCP_ALLOW_UNAUTHENTICATED/EDDI_SECRETSTORE_ALLOW_UNAUTHENTICATEDare no longer derived from OIDC being off (defaultfalse, require explicit opt-in); NOTES.txt warns wheningress.tlsis empty. In-chart MongoDB now runs with--authand a requiredmongodb.rootPassword, its credentialed connection string moving to the projected Secret; NetworkPolicies isolate the MongoDB / PostgreSQL / NATS / Keycloak pods to the EDDI pod.Container image (
src/main/docker/Dockerfile): application files are copied--chown=root:0so the runtime user (185) cannot overwrite its own jars;USER 185and the digest-pinned base are unchanged.
Deliberately deferred / not changed (verified against current files)
Keycloak Helm
KC_BOOTSTRAP_ADMIN_PASSWORDstaysvalue: {{ required … }}— a deliberate two-path design (secretKeyRefon kustomize,requiredvalue on Helm) thatDeploymentManifestsTestenforces.Seeded
viewer/vieweranduser/userandeddi-frontenddirect-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 againsthttp://<external-ip>:7070never succeeded and the advertised dashboard/API/MCP were unreachable on the VM.gcp/provision-vm.shnow exportsEDDI_BIND=0.0.0.0into the VM startup script (off-box exposure stays governed by the IP-scoped firewall and the auth/--i-understand-publicgate);install.shexports and persistsEDDI_BINDto.envsoeddi restartkeeps 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.demonow pre-creates/opt/eddi/data(chown 185:0) beforeUSER 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.txtwarns that the shipped realm seedsviewer/vieweranduser/userwith 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 oneddi-frontend).Cross-branch dependency: the secrets branch adds a vault master-key strength gate that rejects the placeholder key
docker-compose.openwebui.ymldefaults to; that file now setsEDDI_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.rootPasswordrequired broke themanifest-lintjob'shelm template/helm lintruns, which render the chart without it..github/workflows/ci.ymlnow 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
BoundedBodyReadernow reads through a byte cap and a wall-clock deadline (the same shape as the crawler'sSafeHttpPageFetcher.readBounded), exposed onSafeHttpClientassendValidatedBounded/sendBoundedreturning aBoundedResponse.AttachmentForwarder,WebScraperToolandPdfReaderTooluse it, capped to the per-file/tool limits (eddi.attachments.max-forward-bytes, neweddi.tools.web-scraper.max-response-bytes, neweddi.tools.pdf-reader.max-download-bytes).AttachmentTextExtractornow 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).
HttpClientWrappernow streams the Vert.x response into a size-capped sink (CappedBufferSink) that fails the transfer mid-stream atmaxLengthinstead of buffering then rejecting.A2AToolProviderManagerreads the Agent Card and task responses throughBoundedBodyReaderat its 1 MB cap.OpenAPI spec parsing fetched external
$refsand read local files (MEDIUM).McpApiToolBuilder.parseSpecnow setsparseOptions.setSafelyResolveURL(true)so every$refthe parser follows goes through swagger-parser's blocked-URL resolver, and runsrejectCloudMetadataTargeton the spec location before fetching —isValidHttpUrlalone lethttp://169.254.169.254/...through. Stale Javadoc corrected.Shared cookie jar across all users (MEDIUM).
HttpClientModulebuilt one application-scopedWebClientSession, so aSet-Cookiefrom user A's httpcall was replayed on user B's call to the same host. It now uses a plainWebClientwith no cookie store;VertxHttpClientandHttpClientWrapperupdated to the plain type.URL-validator range/family gaps (LOW).
UrlValidationUtilsnow unpacks and re-checks every IPv6 embedding of an IPv4 address (IPv4-compatible::/96, NAT6464:ff9b::/96and64:ff9b:1::/48, 6to42002::/16, Teredo2001::/32server and client), and blocks the missing IPv4 ranges198.18.0.0/15, the limited broadcast255.255.255.255and192.0.0.0/24, plus the Azure WireServer168.63.129.16and OCI192.0.0.192metadata endpoints. (The rest of240.0.0.0/4is deliberately left reachable — the shipped validator contract, pinned byUrlValidationUtilsDeepBranchTest, 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) andAgentSetupService(llmBaseUrl) now callrejectCloudMetadataTargetwhere 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).
ApiCallExecutornow 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 customX-Api-Keywould 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.unzipnow caps entry count, per-entry inflated bytes and total inflated bytes, counted as read (never trustingentry.getSize()), aborting with anIOException. 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-onlysetFollowRedirects(false)missed it.HttpClientModulenow installsstrippingCrossOriginCredentialson the shared client's redirect handler (composed with the existing metadata veto): it removesSafeHttpClient.SENSITIVE_HEADERSfrom any hop whose origin (scheme/host/effective-port) differs from the originating request, regardless of where the credential came from.SafeHttpClient.SENSITIVE_HEADERSis now public so both layers share one set. The false "Vert.x strips only the RFC three" comments inApiCallExecutorand the stale buffering comment inHttpClientWrapperare corrected; the ApiCallExecutor reference-credential redirect disable stays as the stronger measure for resolved secrets. New tests inHttpClientModuleTest(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.
A2AToolProviderManagergained a daemonScheduledExecutorServiceand passes it toBoundedBodyReader.read, so a peer that sends 200+headers then stalls the body cannot hang the worker (the JDK request timeout does not boundofInputStreambody reads). Covered by a newBoundedBodyReaderTestcase with a real scheduler and a stream that blocks until closed (@Timeout(10)).Inline OpenAPI spec filesystem
$refclosed.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.parseSpecnow scans inline content and rejects any$refthat is neither an internal fragment (#/…) nor anhttp(s)URL. NewMcpApiToolBuilderTestcases.
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
$refguard now inspects the decoded document, not only the raw text. A JSON or YAML escape in the key (a Unicode- or hex-escaped$) hid$reffrom the text scan while swagger-parser still resolved it — so"<escaped>ref": "/etc/passwd"slipped past.McpApiToolBuilder.rejectUnsafeInlineRefskeeps the text scan as a first gate and then walks the tree built exactly asOpenAPIV3Parser.readContentsbuilds 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). NewMcpApiToolBuilderTestcases for the escaped key in JSON and YAML, and an escaped internal#/ref that must still pass.WebScraperToolflags 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. NewWebScraperToolExtendedTest.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(eddi.tools.web-scraper.max-response-bytes,eddi.tools.pdf-reader.max-download-bytes,eddi.attachments.extraction.max-pages) —ConfigurationReferenceCoverageTestrequires every read property to be written down.HttpClientWrappergained a package-privateexecutePipedSendseam so the streamed-body behaviour is unit-testable: only the Vert.x transport (which needs a live exchange) is stubbed, whiledoSend's body encoding and the fullhandleResponselogic (Content-Length check, body-from-sink, size-cap → 503, header handling) run for real.HttpClientWrapperSendBranchTestrewritten to the streaming flow and now also asserts the sink actually caps an oversize body.
Known residuals (documented, not fixed here)
WebSearchToolandWeatherToolstill useSafeHttpClient.send(..., ofString())unbounded; their responses are small API JSON, but they are not yet on the bounded path.UrlValidationUtilsunpacks 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
/saycontext key starting withoutput(orquickReplies) of typeobjectbecomes agent output, and the templating task rendered it.OutputGenerationTasknow stores those entries with the newIData#isVerbatim()flag set, andOutputTemplateTaskskips verbatim entries.This also covers the output that an httpcall's
postResponsebuild instructions produce. It travels the samecontext:outputpath and has already been rendered once, with the HTTP response substituted in.The flag is persisted in
ResultSnapshot, asverbatim, 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/:postTemplatedtwins verbatim too. An agent whose workflows each end withai.labs.templatingwould 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 —
fromObjectPathvalues.PropertySetterTaskandPrePostUtils.executePropertyInstructionssent a String resolved throughfromObjectPathto the templating engine. Such a value is now stored as resolved. The authoredvalueStringis still a template.Sub-agent system prompts (review #8).
CreateSubAgentToolstored the system prompt that the parent model writes, which a chat user can steer, as a live template thatLlmTaskrenders on every turn. The tool now wraps it inTemplateEscaping.unparsedBlock, so the rendered prompt is byte-identical and inert.unparsedBlocknow 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).TemplateEscapingTestpins 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 byMemoryItemConverter,LlmTaskand 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
ResourceSharingServiceasks 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
personaand outrank an agent's legacypersona.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, andResourceClientLibrarybypasses 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).
ResourceSharingServicefires aSharingChangedEvent(a CDI event, so the spaces package does not depend on the LLM module) after it writes a grant, revoke, visibility change or transfer.PromptSnippetServiceobserves 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>:contextcollides with an output-set action literally namedcontext, and the quick-reply key suffix is chosen by the client. The flag isverbatimwith defaultfalse, so a Mockito mock ofIDatakeeps 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: trueon 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=fulland the MCPget_approval_statustool withdetail=full. It is omitted while false. The simple conversation views and every other REST and MCP response are unchanged.
Behaviour:
Context-supplied output and
fromObjectPathstrings 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, Data.java, ConversationMemorySnapshot.java, ConversationMemoryUtilities.java, OutputGenerationTask.java, OutputTemplateTask.java, PropertySetterTask.java, PrePostUtils.java, CreateSubAgentTool.java, PromptSnippetService.java, MemoryItemConverter.java, LlmTask.java, CounterweightService.java, ResourceSharingService.java, SharingChangedEvent.java, 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,CounterweightServiceTestandMemoryItemConverterNamespacesTest.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 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 inWeatherToolExtendedTest, used to prove the operator's key never reaches the model or the log.#838 (
fix/secret-scope-vault): one fixture token inPrePostUtilsSecretScopeTestandPropertySetterTaskSecretScopePathsTest, 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 inRestExportServiceTest. 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): fourcurl-auth-userhits ininstall.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
ClientContextGuardremoves 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 reachIConversationServicedirectly 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.
groupIdproperties no longer select a memory scope. Theusermemorytool's group scope used to fall back to agroupIdconversation 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 asgroupId, 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, RestAgentEngine.java, RestAgentEngineStreaming.java, McpConversationTools.java, DynamicAgentToolsProvider.java, ContextualToolsProvider.java, passing-context-information.md, user-memory.md
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:andcdi:resolve to nothing, andstr:evalis dropped;sections:
if,for/each,let/set,with,when/switch(noinclude,insert,eval,fragment,cache, user tags);ReflectionValueResolverreplaced byPropertyAccessValueResolver: record components, public no-argument getters and public fields only — no method with arguments, no non-getter methods, nogetAndXread-modify-write methods (AtomicInteger.getAndIncrementis 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) andeddi.templating.max-iterations(default 100,000; summed over nested loops, checked before a loop starts).0disables 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
fromObjectPathvalue happened to contain template syntax used to have it evaluated; it is now stored literally. Move any intended template intovalueString.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, PropertyAccessValueResolver.java, BoundedLoopSectionHelperFactory.java, TemplatingEngine.java, application.properties, McpApiToolBuilder.java; docs: security.md, output-templating.md, properties.md, httpcalls.md, configuration-reference.md, AGENTS.md.
Known limits / next
An OpenAPI
servers[0].urlcontaining 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 ofLlmTask.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.
VaultMasterKeyStrengthrejects a weak or publicly-known vault master key (too short, too low-entropy, or a well-known demo/placeholder such as thedocker-compose.openwebui.ymlkey).VaultSecretProviderfails startup in production and warns in development/test, mirroringAuthStartupGuard. A production opt-outeddi.vault.allow-weak-master-key(defaultfalse, mirroringeddi.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 viaPOST /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 overtenantId + NUL + plaintext(VaultChecksum) 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-kekre-wraps it with the new KEK alongside the DEKs, so it unwraps to the same value before and after and everyh1: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 throughISecretProvider.matchesChecksum(the caller no longer holds the key).Ciphertext bound to its row (GCM AAD). Secrets are now sealed with
tenantId|keyName|dekIdas 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 thatSecretResolverrefuses 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 underrequirePeerVerification, anddocs/architecture.mdis 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 theeddi-adminrole.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, anAuthorizationheader, …) only an auth-scheme word such asBearerand 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$setOnInserton the unique meta-key index, PostgresON 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-keknow refuses, in production, a new master key the startup strength gate would reject (theallow-weak-master-keyopt-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 aPersistenceException— 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 asSecretProviderException.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 anAuditEntryschema 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):
allowedAgentsis authenticated only at the deploy-time grant check, not bound into the ciphertext (binding it as AAD would break the no-re-encryptupdateGrant); 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, default120000) andtoolTimeoutsMs(new,Map<String, Integer>) — declared, defaulted and resolved exactly like the neighbouringdefaultRateLimit/toolRateLimitspair, including the dispatch-name-before-slug key precedence that makes the documented slug form bind at all.-1(or0) means no bound.ToolLoopRunner.resolveToolTimeoutMs/defaultToolTimeoutMs(task)— the resolution table, sitting next toresolveRateLimit. Read off thetaskparameter rather than threaded down as yet another argument, the waytoolPricingalready is two lines above.ToolExecutionService.executeToolWrapped(ToolInvocation, …)takes a tenth argument,int timeoutMs, and wraps the execution stage inexecuteBounded. 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-widenewVirtualThreadPerTaskExecutor, 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 existingError: 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, taggedtool, incremented alongsideeddi.tool.execution.failure(the same pairingratelimitedalready uses). Documented inmetrics.mdand charted as targetEof the "Execution outcomes" panel ineddi-full-metrics-dashboard.json. Joined by the gaugeeddi.tool.execution.abandonedand 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), withEddiToolBridge's thread-local conversation id, and with the OpenTelemetryContextso a tool's spans stay children ofeddi.pipeline.taskinstead of becoming roots. Without the first of those,${caller:token}andPER_USERconnections would have started failing closed inside every timed tool call.The
Stringoverload keeps its nine arguments and passesTIMEOUT_DISABLED: its one caller isMcpCallsTask, whose tools are bounded byMcpCallsConfiguration.timeoutMs(30s) and which has noLlmConfiguration.Taskto read a timeout from.
Files: ToolExecutionService.java, ToolLoopRunner.java, LlmConfiguration.java, EddiToolBridge.java, langchain.md, 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 underdefaultToolTimeoutMs: 1; exactly one execution reaches the service and it is the ungated one. Two-sided on purpose: a barenever()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, 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.
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).
initialOfinui/manager/src/lib/user-display.tsupper-cased the matched letter and returned the whole result, but upper-casing can lengthen a letter:"ß".toUpperCase()is"SS"and the ligature"fi"is"FI". A user named ßeta Müller therefore got the three-character"SSM"in an avatar sized for two, and fiona ß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 touser-display.test.ts; mutation-checked against the old one-liner.The NVIDIA GPU overlay is documented (PR #752).
docker-compose.ollama-nvidia.ymlshipped naming itself in its own first line and appearing nowhere else in the repository — not the README, not a page underdocs/— so the documented Ollama command did not enable GPUs and nothing said a second-fwas 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 thegpusservice 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: allis 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, whichComposeStackTestenforces) and that the two tags must stay equal, because layering a lower one downgrades Ollama.ComposeStackTestnow 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 underdocs/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 indocs/monitoring/ordocs/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, thechangelog/archives, thechangelog.d/fragments) anddocs/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 — withFiles.walkput back toFiles.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.mdsaid 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,currentPhaseIndexand 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.tsxnow 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/streamopens round 2 and later withround_start, never withgroup_start, and the stream handled onlygroup_start. So a live continuation appended no question and never movedroundStartIndex. 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 withgroup_start, a frame the backend does not send there, so they passed.round_startis now handled, andRoundStartPayloadis 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.
approveAndStreamandcontinueStreamnow 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/groupwrite was keyed on(userId, key, sourceAgentId)with no visibility term, so it matched theglobalentry the same agent had created and flipped the shared memory to private — every other agent lost it. Reproduced through REST and through therememberFacttool ("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.javaDream 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 tosummarizeTargetEntries— 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 whosesummarizationPromptis 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 withinsertIfAbsent, 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.insertIfAbsentnow returns the new entry's id, which the rollback needs. PostgreSQL implements it atomically too (INSERT … ON CONFLICT … DO NOTHING RETURNING idagainst the existing unique partial indexes), where it had inherited the check-then-write default and, throughupsert'sDO UPDATE, could still overwrite the concurrent entry.DreamService.javascope: "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 reportsautoVaultedSecretsDeleted. 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'svaultKeyNamereject it — because the GDPR sweep matches slots by name. Legacy shared slots are never deleted by this — they may still back other users' conversations.ConfigReferenceGuardaccepts 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,PropertySetterTask.java,RestConversationStore.java,GdprComplianceService.javaA property's
visibilitywas ignored.PropertySetterTaskandPrePostUtilsbuilt every property asnew Property(name, value, scope), so"visibility": "self"on alongTermproperty was stored under the agent default —globalfor most agents, and agent B read agent A's "private" properties live. The instruction's visibility is now carried on every branch, and the inlinesetOnActionsparser reads it too (an unknown value fails configuration instead of silently becoming global).PrePostUtils.javauserMemoryConfigwas dropped unlessenableMemoryToolswas on. ItsdefaultVisibilityand recall settings govern thelongTermproperty 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 asglobal. The config now applies whenever it is declared; a new, separatememoryToolsEnabledswitch (carried agent → properties handler → conversation memory) is what attaches theUserMemoryTool, so declaring a config never grants the cross-conversation write tool on its own.AgentStoreClientLibrary.java,ContextualToolsProvider.javagroup-visiblelongTermproperties were unreachable. They were persisted with an emptygroupIds, 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 toConversationGroupsso both write paths share it); agroupproperty in a conversation that belongs to no group is stored asself, the only scope that keeps it reachable without widening it. The group comes from thegroupIdcontext value only — the shared resolver carries main's removal of thegroupIdproperty 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 sharedglobalrow and this agent's own scoped row, which coexist for instance afterdefaultVisibility: selfstarts writing a key an older version stored globally — loading now keeps the most specific one (self, thengroup, thenglobal; 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,ConversationGroups.java,UserMemoryEntry.javaautoRecallCategorieswas never read — now documented as reserved. Recall loads every visible category. It is deliberately not switched on: the store serializes the wholeuserMemoryConfigblock, so the default["preference", "fact"]sits in every stored agent and is indistinguishable from an explicit setting — enforcing it would silently stop recallingcontext,legacy(migrated v5) andpropertyentries for all of them.The deprecated
maxSummarizationCallsceiling applied to every stored agent. The getter wrote the default10into 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 whatisMaxSummarizationCallsSet()exists to tell apart. It is now serialized only when set. Agents already stored keep the10they were given; that bounds a dream cycle only past ten consolidated groups per user, which the per-category grouping rarely reaches.AgentConfiguration.javaThe 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-valuebecameOLD-v5-value— and a single legacy document without auserIdcounted as a failure, so the source was never retired and the clobbering repeated at each boot. A key the user already has inusermemoriesas 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 newIUserMemoryStore.insertIfAbsent, atomic in MongoDB ($setOnInsertonly), so a value another node writes meanwhile cannot be replaced either. An unowned document is skipped instead of failed — it stays readable inproperties_migrated_v6.PropertiesMigrationService.javarememberFact: corrections refused at the cap, budget spent on re-saves. WithonCapReached: "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 spentmaxWritesPerTurnon 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.javaDream and the rolling summary could not select an Ollama (or Bedrock/Azure/Vertex) model.
SummarizationServicealways wrote the model asmodelName; the Ollama builder readsmodel(Bedrock, HuggingFace and Vertex readmodelId, AzuredeploymentName), so every Dream cycle on Ollama failed withmodel is required. The model now goes under the provider's key, and an unsetllmModelno longer overwrites the inherited model withnull.SummarizationService.javaThe documented Dream schedule was rejected, and a user-less one accepted. The schedule JSON in
docs/user-memory.mdhas nomessage— a Dream fire dispatches toDreamService, not to a conversation — and failed with "message is required for CRON triggers". Conversely a Dream schedule without auserIdwas 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.javaDream 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
selfmemory does not belong in an INFO log.DreamService.javaStrict 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 inERROR, a rule ontask_failed_ai.labs.llmcould only answer on the next turn (and the one after failed again), the "concise" digest carrieddev.langchain4j.exception.ModelNotFoundExceptionand the provider's raw JSON body, andkeep_allemitted notask_failed_*action at all although the documentation promises it whenever the discipline is on. New opt-instrictWriteDiscipline.continueOnFailurekeeps running the remaining tasks after the failure is recorded, so an output keyed ontask_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 itsmessage(a stringerroronly when there is nomessage), andkeep_allemits 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 undercontinueOnFailurethe failed task's output cannot reach the reply.LifecycleManager.javaUndo 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
longTermproperty 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;ConversationServicecarries thelongTermpart 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 sharedglobalrow 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'suserMemoryConfig), so undoing a visibility change undoes it in the store and a redonegroupproperty comes back asgroup. 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,ConversationService.javaREST and MCP memory writes: a 500, no limits, unreadable entries, all-required MCP args.
PUT /usermemorystore/memorieswithoutvisibilitycrashed 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 aself/groupentry that no agent could ever read (no owning agent, no group). Both paths now shareUserMemoryWriteRules:visibilityrequired,self/groupneed asourceAgentId,groupneedsgroupIds, known categories only (legacy/propertyincluded), keys ≤ 255 and values ≤ 64 KiB of JSON; an absent category is stored asfact. MCPupsert_user_memorygained agroupIdsargument, and the optional arguments of the memory tools are finally declaredrequired = false— every one used to be advertised as required.UserMemoryWriteRules.java,RestUserMemoryStore.java,McpMemoryTools.javaRecall 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
intmeans the last summarized turn instead of throwingNumberFormatExceptionout of the tool, and a range ending atInteger.MAX_VALUEno longer overflows into an empty result.ConversationRecallTool.java,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, sototalCostmay arrive out of order; a consumer keyed onattributionKeythat 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.
executeGroupMemberTurngained a listener parameter rather than a parallel announce-queue. Its only production caller already had one in scope; the queue machineryannounceArtifactChangesneeds exists because tools hold no listener reference, which is not the case here.Save-time warning only for a 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, GroupConversationEventSink.java, GroupConversation.java, AgentGroupConfiguration.java, MemberTurnExecutor.java, GroupConversationService.java, AgentGroupStore.java, 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
absentandpendingare different cells and must not collapse.absentmeans the phase's selector never included that member (a MODERATOR-only synthesis, aROLE:PROrebuttal);pendingmeans 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 asabsent— i.e. "excluded" rather than "not yet" — which the digest tests caught.The participant selector is only resolved when it says
ALL.MODERATORandROLE:…need the roster, which not every surface has, so they resolve to unknown and the matrix falls back topending. 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 soagent-adoes not absorbagent-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.
splitis 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-0resolves 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.tsxalready 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, discussion-overview.tsx, discussion-panel.tsx, phase-rail.tsx, member-roster.tsx, participation-matrix.tsx, style-recipe.ts, group-detail.tsx, workforce-board.tsx, 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.upToTranscriptIndexis nowcoveredContributions, 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, notenforceCeiling, 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.
updateWindowSummarynow 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
phaseIndexat 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 atroundStartTranscriptIndex.leadSentencereturned"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_RESULTandVERIFICATIONcarry 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".
absentmeans the selector excluded the member; a finishedALLphase with nothing from them means their turn was dropped (maxTurns, a ceiling, aSYNTHESIZE_NOWjump). Newsilentcell 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, andanyString()does not match null. Replaced withverifyNoInteractions.Dead API surface removed —
DigestPhase.expectedSpeakers(always null),streamTotalCost(no consumer), and theonSelectPhase/onSelectMember/onSelectCellprops no surface passed. The phase interaction that was worth keeping is now owned byDiscussionPanelitself (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 madefilereport the source as binary.The question in the headline is now clamped to three lines.
originalQuestionis 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.
🐛 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
totalCostpast 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_updatedfired 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.StanceResultnow carriestextChanged; the stance event keys off it and the cost event offcost(), 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
extrasfirst, lost its principal working surface. Added to all threeextrasnodes, 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
rosterDisplayNamesprop 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 showingsilent/absentcells. Now taken from the merged display-name map, which includes the persistedmemberDisplayNames.group-detailnulled the conversation while streaming, copying what the transcript needs — which defeated the persisted/live cost overlay the previous round had just introduced.continueStreamseeds 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.
🐛 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_TABLEputsrepeats = rounds - 1on its "Discussion" phase andDELPHIis built on repeats end-to-end, so for the two most-used styles the rail was quietly flattening the mechanic that defines them.DigestPhase.repeatswas populated and read by nothing; it now renders as a×Nbadge.Convergence showed the score but not the saving.
convergence.repeatsSkippedis 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, 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, bid-board.tsx, use-discussion-digest.ts, style-recipe.ts, 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-expandedand anaria-controlspointing at the list, matching the task board's toggle. The id comes fromuseIdso 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 and UpgradeExecutor 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 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, StructuralMatcher.java, RestImportService.java, RemoteApiResourceSource.java, SourceUrlValidator.java, SnippetReferences.java, DocumentDescriptorFilter.java, sync-page.tsx, backup.ts, 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:
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.javadrops the first message only when its role isassistant. 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.javacarried a second copy of the same unconditionalremoveFirst()ingenerateMessagesFromOutputs. Both callers passskipSteps > 0today, 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: the blanket "set itfalsefor Anthropic" is gone from the parameter table, the Anthropic example and the troubleshooting section, replaced by an entry for themessages: Field requiredfailure 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.javagainsrolesClaimDiagnostic(), 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 namesQUARKUS_OIDC_ROLES_ROLE_CLAIM_PATHand 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 inTESTand the diagnostic matters there too.OidcRolesClaimConfigTest.javapins the property in the source tree — present, non-blank,realm_access/roles, and not equal toeddi.workspaces.groups-claim. Value-pinned rather than presence-only: an edit togroupswould 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: 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 on a REJECTED verdict. It permits exactly what FAILED permitted — the label is the only thing that changed:
terminal in
GroupLifecycleOps.isTerminalStateand in the coordinator'spersistedTerminalOverrideand cancel guards;closeable —
closeGroupConversation's CAS chain and its refusal message now come from oneCLOSEABLE_STATESlist, so a new terminal state cannot be added to the chain and left out of the error, which is what three hand-writtenifblocks beside a hard-coded sentence invited;availableActionsanswers["close"], as forFAILED;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 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 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: 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 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 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 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: 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 onlyBuild & TestandCodeQL 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/andworktrees/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:
the
RagConfigurationat/ragstore/rags/{id},an
eddi://ai.labs.ragstep in the agent's workflow,knowledgeBases/enableWorkflowRagon 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 continued 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 Stepsection with the workflow JSON, the{ragId}.rag.jsonZIP name, whyRagTask.execute()is a no-op whileconfigure()resolves the KB, and theGET /extensionstore/extensionscheck for builds whereai.labs.ragis 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 thathttpCallRagcalls an external API and cannot query an EDDI knowledge base.REST API: states outright that no
/query,/searchor/retrieveendpoint exists and that those return404. Users were tryinghttpCallRagagainst 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
kbIdquery parameter, found while reviewing this change.RagContextProviderkeys the embedding store onragConfig.getName()and cannot be pointed elsewhere, butRestRagIngestionlets the caller override that key (effectiveKbId, favouring thekbIdparam over the name). AnykbIdother than the KB's exactnametherefore ingests into a store nothing reads —202, statuscompleted, documents genuinely embedded and stored, retrieval empty for ever. Ingestion sources are unaffected:IngestionPipelinekeys onknowledgeBase.getName(), andIngestionRetrievalRoundTripTestpins that round trip after an earlier draft shipped exactly this divergence on the source path.Vector Stores:
in-memoryis no longer described only as "ephemeral, for dev/test only". The cached object is the data, andEmbeddingStoreFactoryholds it in a Caffeine cache bounded at 50 stores with a 30-minuteexpireAfterAccessand 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
## Troubleshootingsection: an ordered seven-step check for the silent-no-context case (task-level RAG settings checked first, ahead of the workflow-binding checks), and thequarkus.log.categoryline 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:
kbIdon/ingestcan 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 akbIdthat does not equal the KB'sname, or to drop the parameter, rather than to document it. Documented here because a doc change cannot make a202mean something else.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
httpCallRagand a wall of 404s.ai.labs.ragregistration is in no release tag. It is onmain; the newest tag is6.4.0. Deployments on6.4.0or 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
ObservableChatModelnow overridesdoChatand delegatesdefaultRequestParameters(),provider()andsupportedCapabilities(), so the inheritedchatbehaves 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) emitseddi.llm.request.duration,eddi.llm.tokensandeddi.llm.request.errors, plus agen_ai.client.inferencespan.
Three design decisions that are not the obvious ones
doChatforwards todelegate.chat(...), notdelegate.doChat(...). AChatModelmay implement either, andJlamaChatModelimplementschat— forwarding todoChatwould 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 ownchat, 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].ObservableStreamingChatModelmoved from overriding bothchatoverloads to overridingdoChat. This was a live defect in an earlier revision of this branch: the two-argumentchat(request, options, handler)is where the streaming interface readslisteners(), 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 passednullfor 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. 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:
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 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(#105, #106) — thestreamLogs"SSE log stream started" DEBUG quotedagentIdandlevelraw. Both are@QueryParams onGET /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 staticsanitizeimport it did not have.listenerIdon the same line is left alone deliberately —BoundedLogStoregenerates it, no caller supplies it, and CodeQL did not flag it.RestConversationStore(#112, #113, #114) — the descriptor loop's "Skipping descriptor due to error" DEBUG, and bothdeleteAttachmentsForConversationlines ("Deleted %d attachments" and "Failed to delete attachments").conversationIdis the path parameter onDELETE /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-importssanitizeand applies it on the neighbouring permanent-delete, soft-delete and not-found lines, so these three were missed rather than deliberately left.NatsConversationCoordinator(#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
sanitizeSubjectas well.subjectisSUBJECT_PREFIX + sanitizeSubject(conversationId), andsanitizeSubjectis 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, whererouteToDeadLetterlogssanitize(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.validateSubjectTermin 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 incatch (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.sayloads 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 throughsanitize.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
RestLogAdmintest, not a mocked one.streamLogsspawns 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.@AfterEachcloses the sink so the thread finishes rather than outliving the test.A 24-character hex conversation id in the
RestConversationStoretest.extractResourceIdresolves 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
getOrCreatenow 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 viadefaultRequestParameters(), which is what carries it through theembed(String)convenience overload thatEmbeddingStoreContentRetrieveruses. 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. 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_DOCUMENTandRETRIEVAL_QUERYdo 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-2never pins. langchain4j sends notask_typefor any model whose name containsembedding-2and applies a role instruction instead, so a pinned task type is already inert there and skipping the role would cost the instruction for nothing.taskTypeis 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. 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 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.createSchedulewould 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-shotoneTimeAt, the unsatisfiable-cron translation, the configured default zone. A second, subtly different copy of it inside the persistence layer is exactly the divergenceMongoScheduleStore.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
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 §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. 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/, 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 — where there is no competing branch to conflict with. The changelog gains a third depth:
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 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:
DocumentationLinksTestrequires every page underdocs/to be reachable fromSUMMARY.md. Fragments are pending entries, not pages, and live about a day. The exemption is kept narrow byChangelogFragmentTest, which allows nothing but aREADME.mdand dated fragments in that directory — so it cannot become somewhere to park a real page.DocumentationAccuracyTestalready skipschangelog.mdand 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.DocumentedRestPathsTestandConfigurationReferenceCoverageTestcarry the same pair of exemptions and needed the same third one, for the same reason.changelog.d/README.mdis 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.
MONTHandDAYstop2026-99-99(which used to sort lexically into the live file and then crash rotation insidepretty_month(), weeks later, in the nightly job), but they still admit2026-02-30— so both the scripts and the test now parse the date as well.--checkvalidates the live file it would write into, not only the fragments. Returning early meant achangelog.mdmissing 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()usesutf-8-sigand the test strips it. The BOM constant in the test is written numerically on purpose — the project formatter rewrites a\uXXXXescape 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-05rows at the top of the Decision Log were left where they are: moving rows reads as adding them, and theChangelog Disciplinejob below would have rejected this PR over a purely cosmetic reorder.update_summary()regenerates the month list inSUMMARY.mdbetween 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 — which also removes the conflict that branch currently has with every other open PR. .github/PULL_REQUEST_TEMPLATE.md and 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, scripts/collate-changelog.py, scripts/rotate-changelog.py, .github/workflows/changelog-collate.yml, .github/workflows/ci.yml, ChangelogFragmentTest.java, DocumentationLinksTest.java, DocumentationAccuracyTest.java, DocumentedRestPathsTest.java, ConfigurationReferenceCoverageTest.java, AGENTS.md, docs/changelog.md, docs/changelog.d/README.md, docs/SUMMARY.md, .github/PULL_REQUEST_TEMPLATE.md, 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
recordIngestedlifts the tombstone and restores the hash those deleted vectors matched, sohasChangedreports "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.
recordUnreachablewrites 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 totombstoneMissing.
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.startRuntakes ownership of the source's rows when it claims a run;reapStaleRunsreleases it (toNULL) 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 newgenerationon 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*andtombstoneMissingcall puts itsrunIdinto the update's own filter. No signature changed and the caller makes no extra round trip — therunIdthe pipeline already passes is the fencing token. PostgreSQL usesON 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_idis separate fromlast_run_id. They mean different things:last_run_idis 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 maderecordUnreachablegrant itself the write permission it is being checked for.ALTER TABLE … ADD COLUMN IF NOT EXISTSalongside theCREATE TABLE.CREATE TABLE IF NOT EXISTSis 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.javasrc/main/java/ai/labs/eddi/modules/ingestion/mongo/MongoIngestionStateStore.javasrc/main/java/ai/labs/eddi/datastore/postgres/PostgresIngestionStateStore.javasrc/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.
reapStaleRunsfails the stale runs and clearsfencing_run_idin 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. EveryrecordSeen,recordIngested,recordUnreachableandtombstoneMissingof 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 withUPDATE ... RETURNING run_idand releasesWHERE fencing_run_id = ANY (?); MongoDB claims each stale run withfindOneAndUpdate— the ids have to come back with the write, not from a read after it — and releases within(fencingRunId, reapedIds).startRun's insert was the one operation still outsidetranslating(...). It handledMongoWriteExceptionand let everything else out, so a connection failure, a timeout or a step-down during the insert escaped as a rawMongoExceptionwhile PostgreSQL answered the same outage withIngestionStateStoreException— the backend-dependent exception contract the helper was added to remove. The duplicate-key case is now handled inside the supplier, so it still returnsOptional.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
supersededRunReportsNothingMissingwas added to the shared contract, where all three implementations run it.tombstoneMissingreports the transition it just performed.bumpAndFindMissingreports its candidates before marking them andDocumentStateis immutable, so the convenience default was handing back a list that still saidtombstoned=falsealthough 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.
mainaddedInMemoryIngestionStateStorewith 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 onstartRun, 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():
it tries
NativeSimdTensorOperations, which needscom.github.tjake:jlama-native— not on our classpath — logs "Native operations not available" and moves on;it falls back to
MachineSpec.VECTOR_TYPE, which readsFloatVector.SPECIES_PREFERREDinside acatch (Throwable). Without--add-modules=jdk.incubator.vectorthat throws, the catch logs one line through Jlama's own logger, and the type staysNONE;NONEselectsNaiveTensorOperations— 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 newENV JDK_JAVA_OPTIONS, not onJAVA_OPTS_APPENDwhere EDDI's other JVM settings live. The launcher readsJDK_JAVA_OPTIONSitself, so the flag survives an operator overridingJAVA_OPTSorJAVA_OPTS_APPEND— and adocker run -e JAVA_OPTS_APPEND=...replaces the image's value rather than appending to it.docs/setup-eddi-on-aws-with-mongodb-atlas.mddoes 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 theENTRYPOINTarray. The demo image starts EDDI with a barejavacommand, so it has neitherrun-java.shnor any ENV to inherit from.pom.xml— the flag on the Surefire fork'sargLine, and deliberately not on the Failsafe fork's. Failsafe'sargLineparameter defaults to the${argLine}property, which bothjacoco:prepare-agent-integrationand 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.argson both tasks that fork a dev JVM,devanddebug.debugwas missed initially and the test could not see it, because it matched only the firstquarkus:devline.JlamaRuntimeSupport(new) — readsMachineSpec.VECTOR_TYPE, the field Jlama's ownTensorOperationsProviderbranches 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 throughModelParameterValues.applyBooleanrather thanBoolean.parseBoolean, so"ture"leaves the provider default instead of silently meaningfalse;workingQuantizedTyperesolves against Jlama'sDTypeenum with an unknown name logged and ignored rather than thrown on every turn. The parameter mapping moved into a package-visibleapplyTothat stops short ofbuild().threadCountis deliberately NOT exposed, though Jlama's builder accepts it and an earlier revision of this branch mapped it.JlamaModel.Loaderhands the value toModelSupport.loadModel, which calls the process-globalPhysicalCoreExecutor.overrideThreadCount— a one-shot latch (if (!started.compareAndSet(false, true)) throw new IllegalStateException(...)) that the executor's memoizedinstancesupplier 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. AndChatModelRegistryrebuilds 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#simdBackendIsAvailableInThisJvmasserts 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.vectorwas resolved. That is a strictly weaker question:MachineSpecaccepts 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. ReadingMachineSpec.VECTOR_TYPEcannot diverge from what Jlama actually does.timeoutstays a pipeline key.JlamaChatModel.builder()has no timeout setter; the value is applied byObservableChatModelas a wall-clock bound. Both tempting "fixes" — adding it torecognisedParameters(), or deleting it from the documented example — would be wrong, soJlamaTests#timeoutIsNotABuilderParameterpins 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 expectedWARNING: Using incubator modulesline;with it on
JDK_JAVA_OPTIONSandJAVA_OPTSorJAVA_OPTS_APPENDoverridden by the operator: stilltrue. That is why the flag lives there and not onJAVA_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. 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
The model suggestions were not resolvable.
MODEL_SUGGESTIONS.jlamaofferedllama-3.2-1bandtinyllama. Jlama resolves a model throughJlamaModelRegistry, which downloads it from Hugging Face and wants anowner/namerepository 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'sdefaultModelcarried the same bare name, which is what the user sees as the model placeholder before typing.A
baseUrlfield for a provider with no endpoint. The wizard offered Jlama a Base URL with ahttp://localhost:8080placeholder. Jlama runs in-process inside the EDDI JVM;AgentSetupServicedropsbaseUrlforjlamabefore the builder is reached, andJlamaLanguageModelBuilder.recognisedParameters()does not contain it either. Anything typed there vanished with no visible trace.…and the field was marked required.
isBaseUrlRequiredreturned true forjlama, which inoperator-activation.tsxis 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.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.
handleProviderChangenow 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(fromdocs/langchain.md) andtjake/TinyLlama-1.1B-Chat-v1.0-Jlama-Q4(from the shipped reference config). Any otherowner/namerepo can still be typed.src/lib/model-suggestions.ts— newsupportsBaseUrl()backed by anIN_PROCESS_PROVIDERSset;isBaseUrlRequired()no longer returns true forjlama.src/pages/agent-wizard.tsx— the Base URL block is omitted entirely whensupportsBaseUrlis 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 (theowner/namerequirement).src/lib/api/agent-setup.ts—defaultModelis 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 — seesrc/main/java/ai/labs/eddi/utils/LogSanitizer.java— it does not remove the printable path itself). An invalidmodelCachePathorworkingDirectorycould 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 otherModelParameterValueswarnings (applyBoolean,booleanValue,rejected) andJlamaLanguageModelBuilder.applyWorkingQuantizedTypewere swept for the same pattern — none of them carry filesystem paths, so they were left as-is.The
jlamaNoteTuningcopy 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 inagent-wizard.tsxand 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/namerule so a user can supply their own.workingQuantizedTypeis parsed case-insensitively and an unknown value keeps Jlama's default.q4is what a user writes;Q4is what the enum calls it.jlama-corestays undeclared inpom.xml.DTypeis imported from it transitively vialangchain4j-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.javasrc/main/java/ai/labs/eddi/modules/llm/impl/builder/ModelParameterValues.javasrc/test/java/ai/labs/eddi/modules/llm/impl/builder/LanguageModelBuildersTest.javasrc/test/java/ai/labs/eddi/modules/llm/impl/builder/ModelParameterValuesTest.javadocs/langchain.md— the Jlama section now states theowner/namerequirement, that there is nobaseUrl, and documents the five parameters in a tableui/manager/src/lib/model-suggestions.tsui/manager/src/lib/api/agent-setup.tsui/manager/src/lib/api/operator.ts— stale "(Ollama, Jlama)" doc commentui/manager/src/components/operator/operator-activation.tsxui/manager/src/pages/agent-wizard.tsxui/manager/src/lib/__tests__/model-suggestions.test.ts(new)ui/manager/src/pages/__tests__/agent-wizard.test.tsxui/manager/src/i18n/locales/*.json(11 files)
Verification
./mvnw compileclean;LanguageModelBuildersTest+ModelParameterValuesTest— the new Jlama andapplyPathcases 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:validateclean);.\mvnw.cmd test "-Dtest=LanguageModelBuildersTest,ModelParameterValuesTest,JlamaRuntimeSupportTest,ChangelogFragmentTest"— all Jlama- andapplyPath-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 buildand 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_PROVIDERSfails the five that pin the wizard's Jlama behaviour; dropping thehandleProviderChangeclear 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 publiceddi-frontendclient, pass it either as a header on a Streamable-HTTP client or throughmcp-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/mcpcannot be opened selectively. The Quick Start now points at it.docs/mcp-server.md— the Configuration block documentedquarkus.mcp-server.http.root-path. That hyphenated form is not a key the extension knows;application.propertiesalready says so. Corrected toquarkus.mcp.server.http.root-pathwith the warning kept.planning/mcp-oauth-protected-resource-plan.md(new) — the fix: advertise/mcpas 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
ResourceMetadataHandlerand appendsresource_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), andSecurityHandlerPriorities.AUTHORIZATIONis 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
rolesclient scope;eddi-frontendgetsrealm_access.rolesonly 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
/mcpin bothresource-metadata.resourceand 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 theauthenticatedpolicy and 401 the discovery request before it starts. The snippet now matchesapplication.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.mdplanning/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, 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/) gaineddi-mcp: public, authorization code + PKCES256required, direct access grant / implicit / service accounts all off, redirect URIshttp://localhost:*andhttp://127.0.0.1:*, no web origins, and therealm-roles,eddi-backend-audienceandgroupsprotocol mappers copied fromeddi-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, thatwebOriginsis empty, and that neithernamenordescriptionexceeds 255 characters: Keycloak stores them inVARCHAR(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 thecodeandbackendpath filters.k8s/andhelm/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-realmnever 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 ininvalid_client. The manual steps are listed,realm-rolesfirst.All three realm copies — the seeded
eddiadministrator gainseddi-vieweralongsideeddi-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.mjsguards that fixture set against the realm and fails the auth E2E run when the two drift, soROLE_FIXTURESande2e/auth/auth-helpers.tsmove 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
clientScopesand defines norolesscope, sorealm_access.rolescomes 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_callbackis 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 indocs/mcp-server.md, rather than a default inherited from us.No
webOrigins, not even+. These clients are native processes;eddi-frontendneeds 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 broadlocalhost/127.0.0.1wildcards 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.jsonsrc/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.javadocs/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. 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.*:enabledtrackstenant-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, andauthorization-serverpreferringtoken.issueroverauth-server-url.application.properties— apermitrule for the two exact metadata paths (the bare form and the path-inserted document),GET,HEADonly. 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:publicUrldescribes 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 owningress.tlsunless 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,openidwhileuser-info-requiredis on, the permit rule's policy/methods/paths, what those paths match and do not match, and/mcpstill beingauthenticated.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/mcpPOST 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 iskubectl port-forward), and the helm chart whenever the Keycloak URL it is given is itself plain http. Without it those deployments advertisehttps://…/mcpwith 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 waydocker-compose.auth.ymldoes, 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@PermitAllalone 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)andSecurityHandlerPriorities.AUTHORIZATIONis 100 — higher runs first, so authorization would answer 401 before the document could be read, and discovery could never start. Verified byjavaponOidcBuildStep, 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-serverdefaults totoken.issuer.auth-server-urlis 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 theissclaim of an accepted token rather than a hardcoded URL.openidis advertised deliberately.user-info-required=truemakes EDDI call userinfo on every request, and Keycloak refuses userinfo for a token minted without that scope; clients copyscopes_supportedinto 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 ashttp://.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-allauthenticatedpolicy 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.McpOAuthDiscoveryConfigTestasserts 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.A2aEndpointPermissionsTestbuilds 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 reportedauthenticatedfor a document Quarkus permits.
Files
src/main/resources/application.propertiessrc/test/java/ai/labs/eddi/configs/McpOAuthDiscoveryConfigTest.java(new)ui/manager/e2e/auth/auth.spec.ts,ui/manager/docker-compose.integration-keycloak.ymldocs/mcp-server.md,docs/security.mdsrc/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, 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=truemeans 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 againsteddi-mcp, the token exchange, then/mcpinitializeand a reallist_agentscall. It asserts the token carriesaud=eddi-backendand 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/v1adapter's 401-under-OIDC entry, which now also means "and carryingaud=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.propertiessrc/test/java/ai/labs/eddi/deploy/DeploymentManifestsTest.javadocs/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—waitForDeploymentCompletionquoted the rawAgentIdon five lines (two "did not complete successfully" ERRORs, the still-deploying DEBUG, the timeout WARN and the interrupted WARN). It now derives onevar 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%son the object for a benign id.logAgentDeployment's two INFO lines (alerts #119/#120, the only two still current at798c6e84) nowsanitize(agentId). The file already sanitized its threedebugfcalls; 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,throwErrorandthrowErrorForbidden, and the four[SCHEDULE]auto-enable/auto-disable lines, which also quoteschedule.getName()andschedule.getId()verbatim. Log levels and message wording are unchanged throughout.RestAgentAdministrationnow static-importssanitizerather than callingLogSanitizer.sanitize(...)on two pre-existing sites, matchingRestAgentStoreandRestWorkflowStoreand AGENTS.md's ban on inline qualification. The class import is gone, so Checkstyle'sUnusedImportsstays 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. ReachingwaitForDeploymentCompletionneeds a publishedIN_PROGRESSplaceholder, so each test parks a deployment inside its store lookup on a virtual thread (the idiomAgentFactoryUndeployVersionTestalready uses) and callsgetAgentfrom 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
AgentFactoryalerts (#116–#118) were last seen atd5294a60and point at lines that carrysanitize(agentId)on today'smain— stale instances that should close on the next scan of the branch. The genuinely unsanitized sinks in that file were the twologAgentDeploymentINFOs plus the fivewaitForDeploymentCompletioncalls, 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. Itsmessageis both logged and put into theWebApplicationExceptionbody, 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
LogCaptureSupportnever sees its message — those tests deliberately use a benign exception message rather than implying coverage they do not have.The
CompletableFuturein the timeout/interrupt tests is hand-written, not mocked. The default mock maker does not interceptCompletableFuture.get(long, TimeUnit): a stubbed mock silently ran the real 60-second wait, and Mockito reported it asUnfinishedStubbingfrom inside the JDK. A four-line anonymous subclass whosegetthrows is deterministic and needs no mock maker at all.Each capture window holds exactly one line under test. The parked deployment's own
logAgentDeploymentINFO 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 byPropertySetterTask.autoVaultSecretand by nothing else. No property-instruction field maps to it (convertPropertyInstructionsreads a fixed key set), and no REST endpoint takes aPropertyas a request body, so "marked" means "this process vaulted it" rather than "this value looks vaulted".ConfigReferenceGuardrequires the marker before it will allow a reference read through{properties.x}. The agent/property/tenant comparison is kept behind it — redundant by construction, sinceautoVaultSecretderives all three itself, and kept as the bound that still holds if a markedPropertyever reaches memory from somewhere other than that method.ApiCallExecutorpasses the liveMap<String, Property>fromIConversationMemoryintobuildRequest→resolveGuardedVariables→ the guard.ConversationProperties.toMap()— what templates and, until now, the guard see — flattens eachPropertyto 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 betweenexecutebeing called and the request being built. Threaded throughexecute,resolveandexecuteFireAndForgetCalls.MemoryCheckpoint.copyPropertiescarries 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
ConfigReferenceGuardTest10 → 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 explicitFALSE, and no properties at all.ApiCallExecutorConfigReferenceTest+1: the requestautoVaultedPropertyHeadersends, refused byte-for-byte when the same property is unmarked — the vault is never asked and nothing is sent.PropertySetterTaskSecretScrubTest+2: ascope: "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,
autoVaultSecretnot 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. 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
SourceConnectorinterface 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
SERVICEconnections; aPER_USERgrant is spent without its owner present only for a source that owner saved, and by default only into a knowledge base whoseaudienceisOWNER— enforced at retrieval inRagContextProviderand 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-restrictedMail.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: 9has carried a seventh target since the store-conflict counter landed. The description is now "Everyeddi_conversation_*_count_totalcounter" 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.
stubConversationExistsreturned only theResultSet, so its own comment — "the test verifies the store closes both" — was not true of thePreparedStatement. It now returns aProbeResourcesrecord and both tests verifyclose()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.
storeConversationMemorySnapshotopened withjsonSerialization.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_serializesTheSnapshotOnceForTheBodypins it at the one callappendConversationStepslegitimately 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=fullnow serializessanitizePendingToolCallsForApprover(snapshot), the same call the REST surface makes.ConversationMemoryUtilities.stripRequestFingerprintsForReadis nowprivate. 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 inargumentsRaw,chatTranscriptJsonandtraceSoFar, serializes through the realJsonSerialization(a mocked serializer hides which fields ride along), and asserts the canary is absent whileargumentsRedactedis 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 theGroupConversationunmodified. That document carries noPendingToolCallBatch, noargumentsRawand no LLM transcript JSON — itstranscriptis the group discussion itself, which is the documented content of the full view, andhitlLastPauseFingerprintdigests 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 buildspauseDetailsfromargumentsRedacted, re-redacted);RestConversationStoreraw log (redactRawPendingToolCallsForRead, names only) and simple log (convertSimpleConversationMemory, names only);ConversationService.readConversationand every say/resume response (convertSimpleConversationMemorySnapshot, names only) and so MCPread_conversation, the OpenAI-compatible bridge,ConverseWithAgentTool/CreateSubAgentTool(tool names only); Slack approval cards (SlackHitlSupportreadsargumentsRedacted, 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(MemoryItemConverterexposes no HITL fields).The concurrent change landed first, and this branch absorbed it.
fix/gemini-thought-signatures(#794) addedPendingToolCallBatch.gatingAssistantMessageJson, which embeds every gated call's raw arguments. It dropped that field insidestripRequestFingerprintsForRead, 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-urlmust be a bare origin.SelfUrlResolvernow rejects a value with a path, query, fragment or userinfo (falls back to loopback, logged at ERROR). Every consumer appends an API path verbatim, sohttps://eddi.internal/basesilently retargeted every call andhttp://eddi:7070?tenant=xturned every path into query content. The Manager's activation form applies the same rule by parsing withURL(isOriginOnlyBaseUrlinlib/api/operator.ts) instead of a character class that still admitted?anduser: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.
useActivateOperatorused to undeploy and delete the predecessor beforeverifyGateInstalled/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 — viaGET /agentstore/agents/{id}/currentversion(200 present, 404 absent; newgetAgentCurrentVersioninlib/api/agents.ts), NOT the version-lessGET /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 (resetOperatordeletes 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 inuse-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.
HttpCallToolsProviderno 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
unresolvedself-URL case is documented inhttpcalls.md,hitl.md,AGENTS.mdandui/manager/AGENTS.md;eddi.self.base-urlandeddi.caller-identity.self-release.enabledare added toconfiguration-reference.md(CI'sConfigurationReferenceCoverageTestwas red on their absence).i18n: corrected misspelled terms in the new
hi,ja,koandthstrings.
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