diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java
new file mode 100644
index 00000000..7b6fa869
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDependencyService.java
@@ -0,0 +1,140 @@
+package vip.mate.wiki.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.wiki.model.WikiPageDependencyEntity;
+import vip.mate.wiki.model.WikiPageEntity;
+import vip.mate.wiki.repository.WikiPageDependencyMapper;
+import vip.mate.wiki.repository.WikiPageMapper;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Maintains the experience→fact dependency graph and propagates staleness when
+ * a fact page changes.
+ *
+ *
Dependencies are stored by page id. An edge is accepted only when the
+ * target is a {@code fact}-layer page in the same KB (cross-KB and
+ * experience→experience edges are rejected). When a fact page changes, every
+ * page depending on it is marked stale via a single batch update keyed on the
+ * reverse index, rather than per-row in the ingest transaction.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+public class WikiDependencyService {
+
+ private final WikiPageDependencyMapper dependencyMapper;
+ private final WikiPageMapper pageMapper;
+ private final WikiPageService pageService;
+ private final ObjectMapper objectMapper;
+
+ public WikiDependencyService(WikiPageDependencyMapper dependencyMapper, WikiPageMapper pageMapper,
+ WikiPageService pageService, ObjectMapper objectMapper) {
+ this.dependencyMapper = dependencyMapper;
+ this.pageMapper = pageMapper;
+ this.pageService = pageService;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Replace an experience page's fact dependencies. Rejected targets (missing,
+ * cross-KB, or non-fact) are skipped and returned so the caller can record a
+ * warning. The page's {@code depends_on_json} snapshot is refreshed too.
+ *
+ * @return the list of rejected target ids with a reason
+ */
+ public List setDependencies(Long kbId, Long pageId, List dependsOnPageIds) {
+ List rejected = new ArrayList<>();
+ Set accepted = new LinkedHashSet<>();
+ if (dependsOnPageIds != null) {
+ for (Long target : dependsOnPageIds) {
+ if (target == null || target.equals(pageId)) {
+ continue;
+ }
+ WikiPageEntity targetPage = pageMapper.selectById(target);
+ if (targetPage == null || !kbId.equals(targetPage.getKbId())) {
+ rejected.add(target + ": not found in this KB");
+ continue;
+ }
+ if (targetPage.getArchived() != null && targetPage.getArchived() == 1) {
+ rejected.add(target + ": archived");
+ continue;
+ }
+ if (!isFactLayer(targetPage)) {
+ rejected.add(target + ": dependency target is not a fact-layer page");
+ continue;
+ }
+ accepted.add(target);
+ }
+ }
+
+ // Soft-delete existing edges for this page, then insert the accepted set.
+ dependencyMapper.delete(new LambdaQueryWrapper()
+ .eq(WikiPageDependencyEntity::getPageId, pageId));
+ for (Long target : accepted) {
+ WikiPageDependencyEntity edge = new WikiPageDependencyEntity();
+ edge.setKbId(kbId);
+ edge.setPageId(pageId);
+ edge.setDependsOnPageId(target);
+ edge.setDependencyType("fact");
+ edge.setCreateTime(LocalDateTime.now());
+ edge.setUpdateTime(LocalDateTime.now());
+ dependencyMapper.insert(edge);
+ }
+ try {
+ String json = objectMapper.writeValueAsString(accepted);
+ pageService.setLayerAndDependencies(pageId, "experience", json);
+ } catch (Exception e) {
+ log.warn("[WikiDep] failed to write depends_on_json for page {}: {}", pageId, e.getMessage());
+ }
+ return rejected;
+ }
+
+ /**
+ * Mark every page depending on {@code factPageId} as stale. Returns the
+ * number of pages marked. Idempotent — re-running on already-stale pages is
+ * harmless.
+ */
+ public int markDependentsStale(Long kbId, Long factPageId, String reason) {
+ List edges = dependencyMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(WikiPageDependencyEntity::getKbId, kbId)
+ .eq(WikiPageDependencyEntity::getDependsOnPageId, factPageId));
+ if (edges.isEmpty()) {
+ return 0;
+ }
+ Set dependentIds = new LinkedHashSet<>();
+ for (WikiPageDependencyEntity edge : edges) {
+ dependentIds.add(edge.getPageId());
+ }
+ String reasonJson = buildReasonJson(factPageId, reason);
+ int marked = pageService.markStale(dependentIds, reasonJson);
+ log.info("[WikiDep] fact page {} changed -> marked {} dependent page(s) stale", factPageId, marked);
+ return marked;
+ }
+
+ private boolean isFactLayer(WikiPageEntity page) {
+ String layer = page.getKnowledgeLayer();
+ // Unspecified layer is treated as fact (RFC default), so legacy pages
+ // remain valid dependency targets.
+ return layer == null || layer.isBlank() || "fact".equalsIgnoreCase(layer.trim());
+ }
+
+ private String buildReasonJson(Long factPageId, String reason) {
+ try {
+ return objectMapper.writeValueAsString(java.util.Map.of(
+ "factPageId", String.valueOf(factPageId),
+ "reason", reason == null ? "fact page updated" : reason));
+ } catch (Exception e) {
+ return "{\"factPageId\":\"" + factPageId + "\"}";
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
index 750138c8..5b4ffade 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
@@ -368,6 +368,39 @@ public class WikiPageService {
.set(WikiPageEntity::getProfileVersion, profileVersion));
}
+ /** Set a page's knowledge layer and depends-on snapshot via a partial update. */
+ public void setLayerAndDependencies(Long pageId, String knowledgeLayer, String dependsOnJson) {
+ if (pageId == null) {
+ return;
+ }
+ pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, pageId)
+ .set(WikiPageEntity::getKnowledgeLayer, knowledgeLayer)
+ .set(WikiPageEntity::getDependsOnJson, dependsOnJson));
+ }
+
+ /** Mark a batch of pages stale with a shared reason JSON via a partial update. */
+ public int markStale(java.util.Collection pageIds, String staleReasonJson) {
+ if (pageIds == null || pageIds.isEmpty()) {
+ return 0;
+ }
+ return pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .in(WikiPageEntity::getId, pageIds)
+ .set(WikiPageEntity::getStale, 1)
+ .set(WikiPageEntity::getStaleReasonJson, staleReasonJson));
+ }
+
+ /** Clear the stale flag on a single page (e.g. after regeneration). */
+ public void clearStale(Long pageId) {
+ if (pageId == null) {
+ return;
+ }
+ pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, pageId)
+ .set(WikiPageEntity::getStale, 0)
+ .set(WikiPageEntity::getStaleReasonJson, null));
+ }
+
/**
* List pages derived from a specific raw material (for UI sidebar filtering).
* Uses a LIKE search on sourceRawIds JSON field — cheap and dialect-agnostic.
diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java
new file mode 100644
index 00000000..870b7065
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiDependencyServiceE2ETest.java
@@ -0,0 +1,99 @@
+package vip.mate.wiki.service;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+import vip.mate.wiki.model.WikiPageEntity;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * End-to-end test of the dependency graph and stale propagation against H2:
+ * valid fact dependencies are recorded, illegal ones rejected, and updating a
+ * fact page marks its dependents stale.
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
+ properties = {
+ "spring.flyway.enabled=true",
+ "spring.flyway.locations=classpath:db/migration/h2",
+ "mateclaw.feature-flag.refresh-ms=999999"
+ }
+)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class WikiDependencyServiceE2ETest {
+
+ @Autowired
+ private WikiDependencyService dependencyService;
+ @Autowired
+ private WikiPageService pageService;
+
+ private static final java.util.concurrent.atomic.AtomicLong SEQ =
+ new java.util.concurrent.atomic.AtomicLong(System.nanoTime());
+
+ private WikiPageEntity factPage(long kb, String slug) {
+ WikiPageEntity p = pageService.createPage(kb, slug, "Fact " + slug, "body", "s", "[1]", "episode");
+ pageService.setLayerAndDependencies(p.getId(), "fact", null);
+ return pageService.getBySlug(kb, slug);
+ }
+
+ private WikiPageEntity experiencePage(long kb, String slug) {
+ WikiPageEntity p = pageService.createPage(kb, slug, "Exp " + slug, "body", "s", "[1]", "analysis");
+ pageService.setLayerAndDependencies(p.getId(), "experience", null);
+ return pageService.getBySlug(kb, slug);
+ }
+
+ @Test
+ void validFactDependency_isRecorded_andStalePropagates() {
+ long kb = SEQ.incrementAndGet();
+ WikiPageEntity fact = factPage(kb, "fact-" + kb);
+ WikiPageEntity exp = experiencePage(kb, "exp-" + kb);
+
+ List rejected = dependencyService.setDependencies(kb, exp.getId(), List.of(fact.getId()));
+ assertTrue(rejected.isEmpty(), () -> "unexpected rejections: " + rejected);
+
+ // Fact page changes -> dependent experience page goes stale.
+ int marked = dependencyService.markDependentsStale(kb, fact.getId(), "fact body changed");
+ assertEquals(1, marked);
+
+ WikiPageEntity reloaded = pageService.getBySlug(kb, "exp-" + kb);
+ assertEquals(1, reloaded.getStale());
+ assertTrue(reloaded.getStaleReasonJson().contains(String.valueOf(fact.getId())));
+
+ // Regenerating clears the flag.
+ pageService.clearStale(reloaded.getId());
+ assertEquals(0, pageService.getBySlug(kb, "exp-" + kb).getStale());
+ }
+
+ @Test
+ void experienceTargetDependency_isRejected() {
+ long kb = SEQ.incrementAndGet();
+ WikiPageEntity expA = experiencePage(kb, "expA-" + kb);
+ WikiPageEntity expB = experiencePage(kb, "expB-" + kb);
+
+ // Depending on an experience page (not a fact) must be rejected.
+ List rejected = dependencyService.setDependencies(kb, expA.getId(), List.of(expB.getId()));
+ assertFalse(rejected.isEmpty());
+ assertTrue(rejected.get(0).contains("not a fact-layer"));
+
+ // No stale propagation since no edge was created.
+ assertEquals(0, dependencyService.markDependentsStale(kb, expB.getId(), "x"));
+ }
+
+ @Test
+ void crossKbDependency_isRejected() {
+ long kbA = SEQ.incrementAndGet();
+ long kbB = SEQ.incrementAndGet();
+ WikiPageEntity factOther = factPage(kbB, "factB-" + kbB);
+ WikiPageEntity exp = experiencePage(kbA, "expA2-" + kbA);
+
+ List rejected = dependencyService.setDependencies(kbA, exp.getId(), List.of(factOther.getId()));
+ assertFalse(rejected.isEmpty());
+ assertTrue(rejected.get(0).contains("not found in this KB"));
+ }
+}