diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/IngestionBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/IngestionBeanConfig.java
new file mode 100644
index 00000000..e33e5648
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/IngestionBeanConfig.java
@@ -0,0 +1,41 @@
+package com.cameleer3.server.app.config;
+
+import com.cameleer3.common.graph.RouteGraph;
+import com.cameleer3.common.model.RouteExecution;
+import com.cameleer3.server.core.ingestion.IngestionService;
+import com.cameleer3.server.core.ingestion.WriteBuffer;
+import com.cameleer3.server.core.storage.model.MetricsSnapshot;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Creates the write buffer and ingestion service beans.
+ *
+ * The {@link WriteBuffer} instances are shared between the
+ * {@link IngestionService} (producer side) and the flush scheduler (consumer side).
+ */
+@Configuration
+public class IngestionBeanConfig {
+
+ @Bean
+ public WriteBuffer executionBuffer(IngestionConfig config) {
+ return new WriteBuffer<>(config.getBufferCapacity());
+ }
+
+ @Bean
+ public WriteBuffer diagramBuffer(IngestionConfig config) {
+ return new WriteBuffer<>(config.getBufferCapacity());
+ }
+
+ @Bean
+ public WriteBuffer metricsBuffer(IngestionConfig config) {
+ return new WriteBuffer<>(config.getBufferCapacity());
+ }
+
+ @Bean
+ public IngestionService ingestionService(WriteBuffer executionBuffer,
+ WriteBuffer diagramBuffer,
+ WriteBuffer metricsBuffer) {
+ return new IngestionService(executionBuffer, diagramBuffer, metricsBuffer);
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ClickHouseFlushScheduler.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ClickHouseFlushScheduler.java
new file mode 100644
index 00000000..aa42083f
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ClickHouseFlushScheduler.java
@@ -0,0 +1,159 @@
+package com.cameleer3.server.app.ingestion;
+
+import com.cameleer3.common.graph.RouteGraph;
+import com.cameleer3.common.model.RouteExecution;
+import com.cameleer3.server.app.config.IngestionConfig;
+import com.cameleer3.server.core.ingestion.WriteBuffer;
+import com.cameleer3.server.core.storage.DiagramRepository;
+import com.cameleer3.server.core.storage.ExecutionRepository;
+import com.cameleer3.server.core.storage.MetricsRepository;
+import com.cameleer3.server.core.storage.model.MetricsSnapshot;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Scheduled task that drains the write buffers and batch-inserts into ClickHouse.
+ *
+ * Implements {@link SmartLifecycle} to ensure all remaining buffered data is
+ * flushed on application shutdown.
+ */
+@Component
+public class ClickHouseFlushScheduler implements SmartLifecycle {
+
+ private static final Logger log = LoggerFactory.getLogger(ClickHouseFlushScheduler.class);
+
+ private final WriteBuffer executionBuffer;
+ private final WriteBuffer diagramBuffer;
+ private final WriteBuffer metricsBuffer;
+ private final ExecutionRepository executionRepository;
+ private final DiagramRepository diagramRepository;
+ private final MetricsRepository metricsRepository;
+ private final int batchSize;
+
+ private volatile boolean running = false;
+
+ public ClickHouseFlushScheduler(WriteBuffer executionBuffer,
+ WriteBuffer diagramBuffer,
+ WriteBuffer metricsBuffer,
+ ExecutionRepository executionRepository,
+ DiagramRepository diagramRepository,
+ MetricsRepository metricsRepository,
+ IngestionConfig config) {
+ this.executionBuffer = executionBuffer;
+ this.diagramBuffer = diagramBuffer;
+ this.metricsBuffer = metricsBuffer;
+ this.executionRepository = executionRepository;
+ this.diagramRepository = diagramRepository;
+ this.metricsRepository = metricsRepository;
+ this.batchSize = config.getBatchSize();
+ }
+
+ @Scheduled(fixedDelayString = "${ingestion.flush-interval-ms:1000}")
+ public void flushAll() {
+ flushExecutions();
+ flushDiagrams();
+ flushMetrics();
+ }
+
+ private void flushExecutions() {
+ try {
+ List batch = executionBuffer.drain(batchSize);
+ if (!batch.isEmpty()) {
+ executionRepository.insertBatch(batch);
+ log.debug("Flushed {} executions to ClickHouse", batch.size());
+ }
+ } catch (Exception e) {
+ log.error("Failed to flush executions to ClickHouse", e);
+ }
+ }
+
+ private void flushDiagrams() {
+ try {
+ List batch = diagramBuffer.drain(batchSize);
+ for (RouteGraph graph : batch) {
+ diagramRepository.store(graph);
+ }
+ if (!batch.isEmpty()) {
+ log.debug("Flushed {} diagrams to ClickHouse", batch.size());
+ }
+ } catch (Exception e) {
+ log.error("Failed to flush diagrams to ClickHouse", e);
+ }
+ }
+
+ private void flushMetrics() {
+ try {
+ List batch = metricsBuffer.drain(batchSize);
+ if (!batch.isEmpty()) {
+ metricsRepository.insertBatch(batch);
+ log.debug("Flushed {} metrics to ClickHouse", batch.size());
+ }
+ } catch (Exception e) {
+ log.error("Failed to flush metrics to ClickHouse", e);
+ }
+ }
+
+ // SmartLifecycle -- flush remaining data on shutdown
+
+ @Override
+ public void start() {
+ running = true;
+ log.info("ClickHouseFlushScheduler started");
+ }
+
+ @Override
+ public void stop() {
+ log.info("ClickHouseFlushScheduler stopping -- flushing remaining data");
+ drainAll();
+ running = false;
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+
+ @Override
+ public int getPhase() {
+ // Run after most beans but before DataSource shutdown
+ return Integer.MAX_VALUE - 1;
+ }
+
+ /**
+ * Drain all buffers completely (loop until empty).
+ */
+ private void drainAll() {
+ drainBufferCompletely("executions", executionBuffer, batch -> executionRepository.insertBatch(batch));
+ drainBufferCompletely("diagrams", diagramBuffer, batch -> {
+ for (RouteGraph g : batch) {
+ diagramRepository.store(g);
+ }
+ });
+ drainBufferCompletely("metrics", metricsBuffer, batch -> metricsRepository.insertBatch(batch));
+ }
+
+ private void drainBufferCompletely(String name, WriteBuffer buffer, java.util.function.Consumer> inserter) {
+ int total = 0;
+ while (buffer.size() > 0) {
+ List batch = buffer.drain(batchSize);
+ if (batch.isEmpty()) {
+ break;
+ }
+ try {
+ inserter.accept(batch);
+ total += batch.size();
+ } catch (Exception e) {
+ log.error("Failed to flush remaining {} during shutdown", name, e);
+ break;
+ }
+ }
+ if (total > 0) {
+ log.info("Flushed {} remaining {} during shutdown", total, name);
+ }
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java
new file mode 100644
index 00000000..ff1fd28b
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java
@@ -0,0 +1,105 @@
+package com.cameleer3.server.app.storage;
+
+import com.cameleer3.common.graph.RouteGraph;
+import com.cameleer3.server.core.storage.DiagramRepository;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * ClickHouse implementation of {@link DiagramRepository}.
+ *
+ * Stores route graphs as JSON with SHA-256 content-hash deduplication.
+ * The underlying table uses ReplacingMergeTree keyed on content_hash.
+ */
+@Repository
+public class ClickHouseDiagramRepository implements DiagramRepository {
+
+ private static final Logger log = LoggerFactory.getLogger(ClickHouseDiagramRepository.class);
+
+ private static final String INSERT_SQL = """
+ INSERT INTO route_diagrams (content_hash, route_id, agent_id, definition)
+ VALUES (?, ?, ?, ?)
+ """;
+
+ private static final String SELECT_BY_HASH = """
+ SELECT definition FROM route_diagrams WHERE content_hash = ? LIMIT 1
+ """;
+
+ private static final String SELECT_HASH_FOR_ROUTE = """
+ SELECT content_hash FROM route_diagrams
+ WHERE route_id = ? AND agent_id = ?
+ ORDER BY created_at DESC LIMIT 1
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+ private final ObjectMapper objectMapper;
+
+ public ClickHouseDiagramRepository(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.objectMapper = new ObjectMapper();
+ this.objectMapper.registerModule(new JavaTimeModule());
+ }
+
+ @Override
+ public void store(RouteGraph graph) {
+ try {
+ String json = objectMapper.writeValueAsString(graph);
+ String contentHash = sha256Hex(json);
+ String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
+ // agent_id is not part of RouteGraph -- set empty, controllers can enrich
+ String agentId = "";
+
+ jdbcTemplate.update(INSERT_SQL, contentHash, routeId, agentId, json);
+ log.debug("Stored diagram for route={} with hash={}", routeId, contentHash);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize RouteGraph to JSON", e);
+ }
+ }
+
+ @Override
+ public Optional findByContentHash(String contentHash) {
+ List