diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1af2f01..ead9960 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,26 @@ may change in any release. See the status note in the README.
## [Unreleased]
+The chat's suggestion warm-up stops spending the chat's day on a chat nobody opens, recovers from a failed or
+unreachable model within minutes, and the pipeline no longer imports the chat. No migration, and no value to set.
+
### Changed
+- **The warm-up waits for the chat to be used, and for the changes to pause.** Before the first open since start, a
+ run's end or a status change prepares nothing; after it, the warm-up starts once `leadgen.chat.suggestion-quiet-window`
+ (default 30 s) has passed without a further change, so triaging applications one by one costs one call from the
+ chat's day per pause rather than one per move. The first open after a start, and an open inside the window after a
+ change, show the catalog's sentences and phrase in the background as any miss does.
+- **A failed phrasing is asked once more after a few minutes.** A call that failed, or a chat model that is named but
+ cannot be built, is left alone for `leadgen.chat.suggestion-retry-after` (default 5 min) instead of until the data
+ moved, so a local model's cold start no longer leaves the catalog's sentences up until the next run.
+- **A miss is phrased in its own language.** Each miss hands over the job it built, so a German miss followed by an
+ English open is phrased once in each language, and the warm-up no longer reads the data a second time.
+- **Follow-ups are no longer stored.** A finished turn's follow-up phrasings stay in memory, so they no longer push the
+ suggestions out of `chat_suggestion_phrasing`'s 256 rows; rows stored by 0.5.6 age out.
+- **The data-change event moved out of the chat.** `chat.suggest.SuggestionDataChanged` is now `event.DataChanged`;
+ `analytics`, `application` and `archive` import nothing of the chat, and `ChatIsolationTest` checks imports and every
+ `chat_*` table the migrations create.
- **Spring AI 2.0.2, with openai-java held at 4.65.0.** 2.0.2 brings openai-java 4.80.0, and from 4.66.0 on a stopped
turn or one past its deadline leaves the model call running (openai/openai-java#1027; 4.96.0 still does). The hold
moves from Spring AI to openai-java, so Spring AI's own fixes land: Anthropic streaming chunks carry the thinking
@@ -29,6 +47,12 @@ may change in any release. See the status note in the README.
2.98.0 for a commit of its own; Gradle 9.9.0 waits for its `gradle:9.9.0-jdk25` image. openai-java 4.101.0 still
fails the four chat-stop tests, so the hold at 4.65.0 stays.
+### Fixed
+
+- **A run finishing during shutdown is no longer logged as not recorded.** The warm-up runs on Boot's
+ `applicationTaskExecutor` instead of an executor of its own, and a data change after shutdown is dropped instead of
+ throwing back into the writer.
+
## [0.5.7] — 2026-10-31
A maintenance release: dependency updates since 0.5.6, one security fix in the frontend, and a test flake fixed.
diff --git a/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java b/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java
index 34facc2..32e3efb 100644
--- a/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java
+++ b/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java
@@ -41,10 +41,8 @@
import de.codeministry.leadgen.chat.suggest.FollowUp;
import de.codeministry.leadgen.chat.suggest.Suggestion;
import de.codeministry.leadgen.chat.suggest.SuggestionCandidate;
-import de.codeministry.leadgen.chat.suggest.SuggestionDataChanged;
import de.codeministry.leadgen.chat.suggest.SuggestionScope;
import de.codeministry.leadgen.chat.suggest.SuggestionThresholds;
-import de.codeministry.leadgen.chat.suggest.SuggestionWarmup;
import de.codeministry.leadgen.chat.tools.ApplicationTool;
import de.codeministry.leadgen.chat.tools.OfferSearchTool;
import de.codeministry.leadgen.chat.tools.PinnedAdvert;
@@ -171,10 +169,8 @@ private static void registerChatRecords(RuntimeHints hints) {
FollowUp.class,
Suggestion.class,
SuggestionCandidate.class,
- SuggestionDataChanged.class,
SuggestionScope.class,
SuggestionThresholds.class,
- SuggestionWarmup.Job.class,
NewConversation.class,
ModelPick.class,
NewTurn.class,
diff --git a/backend/src/main/java/de/codeministry/leadgen/LeadGenerationApplication.java b/backend/src/main/java/de/codeministry/leadgen/LeadGenerationApplication.java
index aadb92c..cf346c9 100644
--- a/backend/src/main/java/de/codeministry/leadgen/LeadGenerationApplication.java
+++ b/backend/src/main/java/de/codeministry/leadgen/LeadGenerationApplication.java
@@ -20,11 +20,14 @@
* Entry point. Scheduling is enabled here because the pipeline runs inside this
* process (concept § 10, phase 1) — there is no separate worker container.
*
- *
Asynchronous execution is enabled for exactly one thing: {@code PackageWorker}, which
- * writes and removes package folders after the transaction that asked for it has committed.
- * The work is disk and templates rather than a request anybody is waiting on, and Boot's own
+ *
Asynchronous execution is enabled for {@code PackageWorker}, which writes and removes
+ * package folders after the transaction that asked for it has committed. The work is disk and
+ * templates rather than a request anybody is waiting on, and Boot's own
* {@code applicationTaskExecutor} runs it — a second pool for one listener would be one more
- * thing to size and shut down.
+ * thing to size and shut down. The chat's {@code SuggestionWarmup} runs on the same executor,
+ * one model call at a time and for up to the model's timeout; size or bound it with both in mind.
+ * Its quiet window is timed by the JDK's delay timer, not by Boot's {@code taskScheduler}, whose one
+ * thread a scheduled ingest pass holds for as long as it runs.
*/
@SpringBootApplication
// Named rather than scanned: a `@WebMvcTest` slice filters scanned records out but keeps
diff --git a/backend/src/main/java/de/codeministry/leadgen/analytics/PipelineRunRecorder.java b/backend/src/main/java/de/codeministry/leadgen/analytics/PipelineRunRecorder.java
index 5332c00..47bd775 100644
--- a/backend/src/main/java/de/codeministry/leadgen/analytics/PipelineRunRecorder.java
+++ b/backend/src/main/java/de/codeministry/leadgen/analytics/PipelineRunRecorder.java
@@ -8,8 +8,8 @@
*/
package de.codeministry.leadgen.analytics;
-import de.codeministry.leadgen.chat.suggest.SuggestionDataChanged;
import de.codeministry.leadgen.config.ConfigRegistry;
+import de.codeministry.leadgen.event.DataChanged;
import de.codeministry.leadgen.filter.FilterStage;
import de.codeministry.leadgen.ingest.IngestReport;
import de.codeministry.leadgen.score.Judges;
@@ -323,7 +323,7 @@ private void write(
.update();
}
// The counts the empty chat suggests from settle here, not at the start (ISC-529).
- events.publishEvent(new SuggestionDataChanged("run " + status));
+ events.publishEvent(new DataChanged("run " + status));
} catch (RuntimeException e) {
log.error("The run was not recorded: {}", e.getMessage(), e);
}
@@ -347,7 +347,7 @@ public void complete(int scored, int packaged, boolean digestWritten) {
// complete, and a run recorded before this table existed has no row.
log.debug("No run was waiting for a batch, so nothing was completed");
} else {
- events.publishEvent(new SuggestionDataChanged("run completed"));
+ events.publishEvent(new DataChanged("run completed"));
}
} catch (RuntimeException e) {
log.error("The batched run was not completed in the history: {}", e.getMessage(), e);
diff --git a/backend/src/main/java/de/codeministry/leadgen/application/ApplicationService.java b/backend/src/main/java/de/codeministry/leadgen/application/ApplicationService.java
index 4b7fc87..837072d 100644
--- a/backend/src/main/java/de/codeministry/leadgen/application/ApplicationService.java
+++ b/backend/src/main/java/de/codeministry/leadgen/application/ApplicationService.java
@@ -8,7 +8,7 @@
*/
package de.codeministry.leadgen.application;
-import de.codeministry.leadgen.chat.suggest.SuggestionDataChanged;
+import de.codeministry.leadgen.event.DataChanged;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
@@ -174,7 +174,7 @@ private static ApplicationView view(ResultSet rs, int row) throws SQLException {
public OpenReport openShortlisted() {
int opened = jdbc.sql(OPEN_SHORTLISTED).update();
if (opened > 0) {
- events.publishEvent(new SuggestionDataChanged("applications opened"));
+ events.publishEvent(new DataChanged("applications opened"));
}
int standing = jdbc.sql(SHORTLIST_STANDING).query(Integer.class).single();
log.info("Board: {} opened at NEW, {} offers on the shortlist", opened, standing);
@@ -330,7 +330,7 @@ INSERT INTO application_event (application_id, from_status, to_status, note)
.params(applicationId, from == null ? null : from.name(), to.name(), note)
.update();
// Every status write passes here, so the empty chat's suggestions follow it (ISC-529).
- events.publishEvent(new SuggestionDataChanged("application " + to.name()));
+ events.publishEvent(new DataChanged("application " + to.name()));
}
/**
diff --git a/backend/src/main/java/de/codeministry/leadgen/archive/ArchiveService.java b/backend/src/main/java/de/codeministry/leadgen/archive/ArchiveService.java
index 1087d52..621ab19 100644
--- a/backend/src/main/java/de/codeministry/leadgen/archive/ArchiveService.java
+++ b/backend/src/main/java/de/codeministry/leadgen/archive/ArchiveService.java
@@ -9,8 +9,8 @@
package de.codeministry.leadgen.archive;
import de.codeministry.leadgen.application.ApplicationStatus;
-import de.codeministry.leadgen.chat.suggest.SuggestionDataChanged;
import de.codeministry.leadgen.config.ConfigRegistry;
+import de.codeministry.leadgen.event.DataChanged;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collection;
@@ -238,7 +238,7 @@ public ArchiveResult setArchived(Collection offerIds, boolean archived) {
int reset = jdbc.sql(RESET_TO_NEW).param(ids).update();
if (reset > 0) {
log.info("{} restored offers went back to NEW", reset);
- events.publishEvent(new SuggestionDataChanged("restored to NEW"));
+ events.publishEvent(new DataChanged("restored to NEW"));
}
}
return result;
diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/InProcess.java b/backend/src/main/java/de/codeministry/leadgen/chat/InProcess.java
new file mode 100644
index 0000000..f9ee9c1
--- /dev/null
+++ b/backend/src/main/java/de/codeministry/leadgen/chat/InProcess.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Copyright 2026 Marcello Muscara (codeministry)
+ *
+ * Licensed under the Apache License, Version 2.0. You may obtain a copy of the
+ * License at http://www.apache.org/licenses/LICENSE-2.0
+ */
+package de.codeministry.leadgen.chat;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A record of the chat that stays inside the process: it is handed from one bean to another and
+ * never reaches Jackson, so no runtime hint is kept for it (ISC-542).
+ *
+ * Every other record under {@code chat} is read or written as JSON by reflection, and
+ * {@code LeadGenRuntimeHintsTest} fails for one without a hint. This marker is the one way out of
+ * that rule, and it says so where the type is declared rather than in a list inside the test: a
+ * record that later goes into a response or a stored column must lose the marker and gain a hint.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface InProcess {}
diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/suggest/Phrasing.java b/backend/src/main/java/de/codeministry/leadgen/chat/suggest/Phrasing.java
index 783e856..ccbac3d 100644
--- a/backend/src/main/java/de/codeministry/leadgen/chat/suggest/Phrasing.java
+++ b/backend/src/main/java/de/codeministry/leadgen/chat/suggest/Phrasing.java
@@ -13,6 +13,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import de.codeministry.leadgen.chat.ChatBudget;
import de.codeministry.leadgen.chat.ChatCapability;
+import de.codeministry.leadgen.config.ConfigProperties;
import de.codeministry.leadgen.config.ConfigRegistry;
import de.codeministry.leadgen.config.model.PipelineConfig;
import de.codeministry.leadgen.llm.Answers;
@@ -21,6 +22,7 @@
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -50,15 +52,17 @@
*
*
Looked up apart from asked (ISC-527). {@link #stored} never calls a model, so the empty
* chat answers at once; {@link #attempt} is the call itself, which {@link SuggestionWarmup} makes off
- * the request thread. {@link #phrase} does both in a row and stays for the follow-ups, which are
- * asked once a turn has finished rather than while the chat opens.
+ * the request thread. {@link #phrase} is the follow-ups' path, asked once a turn has finished rather
+ * than while the chat opens: a lookup in memory, else a call now.
*
*
Once per snapshot. The caller's key names the data the sentences were found in; the
* model, the language and the sentences themselves are added here, so a changed count, a switched
* model or the other language is a new key and a reopened chat with none of those is a map
- * lookup. The map is cleared when it grows past {@link #CACHE_LIMIT}: its keys come from data
- * snapshots, and an old snapshot is never asked for again. Behind the map sits
- * {@link PhrasingStore}, so a restart costs a table read rather than a model call (ISC-531).
+ * lookup. Each map is cleared when it grows past {@link #CACHE_LIMIT}: its keys come from data
+ * snapshots, and an old snapshot is never asked for again. The suggestions' phrasings sit in front
+ * of {@link PhrasingStore}, so for them a restart costs a table read rather than a model call
+ * (ISC-531). The follow-ups' sit in a map of their own and nowhere else (ISC-539): a finished turn's
+ * keys are not asked for again, and sharing one map would let them clear the suggestions' out.
*
*
A phrasing may change the words, not the numbers. A sentence whose digits differ from
* the catalog's is dropped for the catalog's, so no count and no id reaches the screen that the
@@ -84,23 +88,37 @@ class Phrasing {
private static final String TEMPLATE = load();
+ /** The suggestions' phrasings, in front of the table. */
private final Map> cache = new ConcurrentHashMap<>();
+ /** The follow-ups' phrasings, in memory only (ISC-539). */
+ private final Map> followUps = new ConcurrentHashMap<>();
+
/**
- * Keys whose call failed: not asked again until the data, the model or the language moves the
- * key (ISC-532). A refused take or a missing model is not kept here, since neither sends anything.
+ * Keys whose call failed, or whose named model could not be built, and the instant each may be
+ * asked again: {@code leadgen.chat.suggestion-retry-after} later (ISC-535). Not retried by
+ * itself — the next open or warm-up after that instant asks once more — so a model that is down
+ * costs one call per key and window, never a loop (ISC-532). A refused take is not kept here,
+ * since it sends nothing and the next day answers it. At {@link #CACHE_LIMIT} the passed windows
+ * go first and then the oldest, never the whole map: clearing it would lift every window at
+ * once and send a burst of calls to a model that is still down.
*/
- private final Set failed = ConcurrentHashMap.newKeySet();
+ private final Map skippedUntil = new ConcurrentHashMap<>();
private final ChatCapability capability;
private final ChatModels chatModels;
private final ConfigRegistry config;
private final ChatBudget budget;
private final PhrasingStore store;
+ private final ConfigProperties properties;
/**
- * The sentences as the reader sees them, in the order given: the stored phrasing, else one
- * asked for now, else the catalog's.
+ * The follow-ups' path: the sentences as the reader sees them, in the order given — the
+ * phrasing kept in memory, else one asked for now, else the catalog's.
+ *
+ * Kept in memory only, never in the table (ISC-539): a follow-up belongs to one finished turn
+ * and is never asked for again, so a stored row would only push the suggestions' rows out of
+ * the table's bound.
*
* @param snapshot what the sentences were derived from; equal snapshots share one phrasing
* @param sentences the catalog's sentences by key, in the reader's language
@@ -110,8 +128,8 @@ Map phrase(String snapshot, Map sentences, Strin
return sentences;
}
return key(snapshot, sentences, language)
- .flatMap(this::stored)
- .or(() -> attempt(snapshot, sentences, language))
+ .map(followUps::get)
+ .or(() -> attempt(snapshot, sentences, language, false))
.orElse(sentences);
}
@@ -141,32 +159,38 @@ Optional