diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java
new file mode 100644
index 00000000..5f634c47
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagController.java
@@ -0,0 +1,88 @@
+package vip.mate.system.featureflag;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.common.result.R;
+import vip.mate.system.featureflag.repository.FeatureFlagMapper;
+
+import java.util.List;
+
+/**
+ * Admin endpoints for runtime feature-flag toggling.
+ *
+ *
Authn / authz are delegated to the global security configuration:
+ * the JWT filter populates the principal, and the controller method runs
+ * inside the standard admin-role guard. Edit access should be restricted
+ * to operators; everyday users have no business toggling these.
+ *
+ * @author MateClaw Team
+ */
+@RestController
+@RequestMapping("/api/v1/feature-flags")
+@RequiredArgsConstructor
+public class FeatureFlagController {
+
+ private final FeatureFlagService service;
+ private final FeatureFlagMapper mapper;
+
+ /** Lists every flag currently registered, including disabled and whitelisted ones. */
+ @GetMapping
+ public R> list() {
+ return R.ok(mapper.selectList(null));
+ }
+
+ /**
+ * Updates one flag in place. Only fields explicitly set in the request
+ * body are touched; unspecified fields preserve their current values.
+ */
+ @PutMapping("/{flagKey}")
+ public R update(@PathVariable @NotBlank String flagKey,
+ @RequestBody UpdateRequest req) {
+ FeatureFlagEntity flag = mapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(FeatureFlagEntity::getFlagKey, flagKey));
+ if (flag == null) {
+ return R.fail("Unknown flag: " + flagKey);
+ }
+ if (req.getEnabled() != null) {
+ flag.setEnabled(req.getEnabled());
+ }
+ if (req.getDescription() != null) {
+ flag.setDescription(req.getDescription());
+ }
+ if (req.getWhitelistKbIds() != null) {
+ flag.setWhitelistKbIds(req.getWhitelistKbIds());
+ }
+ if (req.getWhitelistUserIds() != null) {
+ flag.setWhitelistUserIds(req.getWhitelistUserIds());
+ }
+ if (req.getRolloutPercent() != null) {
+ flag.setRolloutPercent(req.getRolloutPercent());
+ }
+ mapper.updateById(flag);
+ service.invalidate();
+ return R.ok();
+ }
+
+ /** Body for {@link #update(String, UpdateRequest)}. */
+ @Data
+ public static class UpdateRequest {
+ private Boolean enabled;
+ private String description;
+ private String whitelistKbIds;
+ private String whitelistUserIds;
+ @Min(0)
+ @Max(100)
+ private Integer rolloutPercent;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java
new file mode 100644
index 00000000..3f907408
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagEntity.java
@@ -0,0 +1,53 @@
+package vip.mate.system.featureflag;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * One row in the runtime feature-flag store.
+ *
+ * Backed by {@code mate_feature_flag}. Read/write paths go through
+ * {@link FeatureFlagService}; no service should query the table directly.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@TableName("mate_feature_flag")
+public class FeatureFlagEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** Stable identifier; convention is {@code ..enabled}. */
+ private String flagKey;
+
+ /** Master switch. When false, all evaluations short-circuit to false. */
+ private Boolean enabled;
+
+ private String description;
+
+ /** Comma-separated KB ids; null/blank = applies to all KBs. */
+ private String whitelistKbIds;
+
+ /** Comma-separated user ids; null/blank = applies to all users. */
+ private String whitelistUserIds;
+
+ /** Percentage rollout (0..100). Only consulted when both whitelists are blank. */
+ private Integer rolloutPercent;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java
new file mode 100644
index 00000000..ff9aec97
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FeatureFlagService.java
@@ -0,0 +1,171 @@
+package vip.mate.system.featureflag;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import vip.mate.system.featureflag.repository.FeatureFlagMapper;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * Runtime-toggleable feature-flag store with in-memory caching.
+ *
+ * Reads are O(1) once the cache is warm; the cache refreshes on a
+ * configurable timer (default 30 s) and immediately on admin write via
+ * {@link #invalidate()}. Multi-instance deployments converge within one
+ * refresh tick; admin writes from one instance are not pushed to peers
+ * (acceptable for a coarse-grained flag system).
+ *
+ *
Evaluation order:
+ *
+ * - If {@code enabled == false}, return false.
+ * - If {@code whitelist_kb_ids} is set and the context has a kbId,
+ * require kb membership; absence in the whitelist returns false.
+ * - Same for {@code whitelist_user_ids}.
+ * - If both whitelists are blank and {@code rollout_percent} is in
+ * (0, 100), use a deterministic hash of the context's keying value
+ * to gate.
+ * - Otherwise return true.
+ *
+ *
+ * Unknown flags evaluate to false (fail-closed). This makes it safe to
+ * remove a flag from the seed list: callers see the feature as disabled
+ * until the flag is re-introduced.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FeatureFlagService {
+
+ private static final FeatureFlagEntity MISSING = new FeatureFlagEntity();
+
+ private final FeatureFlagMapper mapper;
+
+ /** flagKey → resolved entity. {@link #MISSING} sentinel marks "not in DB". */
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ @PostConstruct
+ public void init() {
+ refresh();
+ }
+
+ /** Returns true iff the flag is enabled in the given context. */
+ public boolean isEnabled(String flagKey, FlagContext ctx) {
+ FeatureFlagEntity flag = cache.computeIfAbsent(flagKey, this::loadOne);
+ if (flag == MISSING || !Boolean.TRUE.equals(flag.getEnabled())) {
+ return false;
+ }
+ return matchesContext(flag, ctx == null ? FlagContext.empty() : ctx);
+ }
+
+ public boolean isEnabled(String flagKey) {
+ return isEnabled(flagKey, FlagContext.empty());
+ }
+
+ public boolean isEnabledForKb(String flagKey, Long kbId) {
+ return isEnabled(flagKey, FlagContext.ofKb(kbId));
+ }
+
+ public boolean isEnabledForUser(String flagKey, Long userId) {
+ return isEnabled(flagKey, FlagContext.ofUser(userId));
+ }
+
+ /** Periodic full refresh; covers DB writes that bypass the admin API. */
+ @Scheduled(fixedDelayString = "${mateclaw.feature-flag.refresh-ms:30000}")
+ public void refresh() {
+ try {
+ List all = mapper.selectList(null);
+ ConcurrentHashMap next = new ConcurrentHashMap<>();
+ for (FeatureFlagEntity flag : all) {
+ next.put(flag.getFlagKey(), flag);
+ }
+ cache.clear();
+ cache.putAll(next);
+ log.debug("[FeatureFlag] refreshed {} flags from DB", next.size());
+ } catch (Exception e) {
+ log.warn("[FeatureFlag] refresh failed; keeping previous cache: {}", e.getMessage());
+ }
+ }
+
+ /** Invalidate + reload; called by admin-write paths to make changes visible immediately. */
+ public void invalidate() {
+ cache.clear();
+ refresh();
+ }
+
+ // ==================== internal ====================
+
+ private boolean matchesContext(FeatureFlagEntity flag, FlagContext ctx) {
+ boolean kbWhitelistDefined = isPresent(flag.getWhitelistKbIds());
+ boolean userWhitelistDefined = isPresent(flag.getWhitelistUserIds());
+
+ if (kbWhitelistDefined && ctx.getKbId() != null) {
+ Set kbs = parseIds(flag.getWhitelistKbIds());
+ if (!kbs.contains(ctx.getKbId())) {
+ return false;
+ }
+ }
+ if (userWhitelistDefined && ctx.getUserId() != null) {
+ Set users = parseIds(flag.getWhitelistUserIds());
+ if (!users.contains(ctx.getUserId())) {
+ return false;
+ }
+ }
+
+ // Percentage rollout only consulted when no whitelist applies.
+ if (!kbWhitelistDefined && !userWhitelistDefined) {
+ Integer pct = flag.getRolloutPercent();
+ if (pct != null && pct > 0 && pct < 100) {
+ long key = ctx.getKbId() != null
+ ? ctx.getKbId()
+ : ctx.getUserId() != null
+ ? ctx.getUserId()
+ : flag.getFlagKey().hashCode();
+ return Math.floorMod(key, 100L) < pct;
+ }
+ }
+
+ return true;
+ }
+
+ private static boolean isPresent(String csv) {
+ return csv != null && !csv.isBlank();
+ }
+
+ private static Set parseIds(String csv) {
+ return Arrays.stream(csv.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .map(s -> {
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ })
+ .filter(java.util.Objects::nonNull)
+ .collect(Collectors.toCollection(HashSet::new));
+ }
+
+ private FeatureFlagEntity loadOne(String flagKey) {
+ try {
+ FeatureFlagEntity flag = mapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(FeatureFlagEntity::getFlagKey, flagKey));
+ return flag != null ? flag : MISSING;
+ } catch (Exception e) {
+ log.warn("[FeatureFlag] loadOne({}) failed: {}", flagKey, e.getMessage());
+ return MISSING;
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java
new file mode 100644
index 00000000..d209c156
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/FlagContext.java
@@ -0,0 +1,34 @@
+package vip.mate.system.featureflag;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * Evaluation context passed to {@link FeatureFlagService#isEnabled}.
+ *
+ * Either or both of {@code kbId} and {@code userId} may be set. The
+ * service uses them to match against per-flag whitelists; if both are null
+ * the flag is evaluated against the percentage-rollout dial only.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Builder
+public class FlagContext {
+
+ private Long kbId;
+ private Long userId;
+ private String role;
+
+ public static FlagContext empty() {
+ return FlagContext.builder().build();
+ }
+
+ public static FlagContext ofKb(Long kbId) {
+ return FlagContext.builder().kbId(kbId).build();
+ }
+
+ public static FlagContext ofUser(Long userId) {
+ return FlagContext.builder().userId(userId).build();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java b/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java
new file mode 100644
index 00000000..dc679d4f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/featureflag/repository/FeatureFlagMapper.java
@@ -0,0 +1,17 @@
+package vip.mate.system.featureflag.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.system.featureflag.FeatureFlagEntity;
+
+/**
+ * MyBatis Plus mapper for {@link FeatureFlagEntity}.
+ *
+ *
Mapper interface lives under a {@code repository} sub-package as required
+ * by the application-wide {@code @MapperScan("vip.mate.**.repository")}.
+ *
+ * @author MateClaw Team
+ */
+@Mapper
+public interface FeatureFlagMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql b/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql
new file mode 100644
index 00000000..671f6480
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V78__feature_flag.sql
@@ -0,0 +1,48 @@
+-- mate_feature_flag: runtime-toggleable feature flag store.
+--
+-- Each row defines one named flag with optional KB / user whitelists and
+-- a percentage rollout. Reads go through an in-memory cache that refreshes
+-- on a 30-second timer (and immediately on admin write); the cache is
+-- per-instance so multi-instance deployments converge within one tick.
+
+CREATE TABLE IF NOT EXISTS mate_feature_flag (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ flag_key VARCHAR(128) NOT NULL UNIQUE,
+
+ -- Master switch. When false, isEnabled() returns false regardless of whitelists.
+ enabled BOOLEAN NOT NULL DEFAULT FALSE,
+
+ -- Free-form note shown in the admin UI; not consumed by code paths.
+ description VARCHAR(512),
+
+ -- Comma-separated KB ids; NULL/empty means the flag applies to all KBs.
+ whitelist_kb_ids CLOB,
+
+ -- Comma-separated user ids; NULL/empty means the flag applies to all users.
+ whitelist_user_ids CLOB,
+
+ -- Hash-based gradual rollout (0..100). Only consulted when both whitelists
+ -- are empty; otherwise whitelist matching wins.
+ rollout_percent INT DEFAULT 0,
+
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted INT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_mff_key_enabled
+ ON mate_feature_flag (flag_key, enabled, deleted);
+
+-- Seed wiki feature flags with safe defaults.
+-- New capabilities ship disabled and are turned on via admin API or via
+-- editing whitelist_kb_ids during gradual rollout.
+MERGE INTO mate_feature_flag (flag_key, enabled, description) KEY (flag_key) VALUES
+ ('wiki.ocr.enabled', FALSE, 'Image OCR / vision-in pipeline for wiki uploads'),
+ ('wiki.compile.4stage.enabled', FALSE, 'Four-stage knowledge base compilation pipeline'),
+ ('wiki.compile.cache.enabled', FALSE, 'Prompt cache layer for the wiki compile pipeline'),
+ ('wiki.confidence.enabled', FALSE, 'Confidence taxonomy on wiki relations and pages'),
+ ('wiki.hot_cache.enabled', FALSE, 'KB-level recent-activity snapshot injected into agent system prompt'),
+ ('wiki.graph.insights.enabled', FALSE, 'Wiki graph insights panel (surprising connections, gaps, bridges)'),
+ ('wiki.graph.adamic_adar.enabled', FALSE, 'Adamic-Adar graph signal (additive to existing four signals)'),
+ ('wiki.graph.boundary.enabled', FALSE, 'Boundary score for surfacing dangling pages'),
+ ('wiki.relation.cache.enabled', TRUE, 'Persistent cache for wiki page-to-page relation computation');
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql
new file mode 100644
index 00000000..e0ae5e48
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V78__feature_flag.sql
@@ -0,0 +1,38 @@
+-- mate_feature_flag: runtime-toggleable feature flag store.
+--
+-- Each row defines one named flag with optional KB / user whitelists and
+-- a percentage rollout. Reads go through an in-memory cache that refreshes
+-- on a 30-second timer (and immediately on admin write); the cache is
+-- per-instance so multi-instance deployments converge within one tick.
+
+CREATE TABLE IF NOT EXISTS mate_feature_flag (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ flag_key VARCHAR(128) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ description VARCHAR(512),
+ whitelist_kb_ids TEXT,
+ whitelist_user_ids TEXT,
+ rollout_percent INT DEFAULT 0,
+
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ deleted TINYINT NOT NULL DEFAULT 0,
+
+ UNIQUE KEY uk_mff_key (flag_key),
+ KEY idx_mff_key_flag (flag_key, enabled, deleted)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
+ COMMENT = 'Runtime-toggleable feature flag store with KB/user whitelists and percentage rollout.';
+
+-- Seed wiki feature flags with safe defaults. ON DUPLICATE KEY UPDATE
+-- preserves existing operator overrides on re-run.
+INSERT INTO mate_feature_flag (flag_key, enabled, description) VALUES
+ ('wiki.ocr.enabled', FALSE, 'Image OCR / vision-in pipeline for wiki uploads'),
+ ('wiki.compile.4stage.enabled', FALSE, 'Four-stage knowledge base compilation pipeline'),
+ ('wiki.compile.cache.enabled', FALSE, 'Prompt cache layer for the wiki compile pipeline'),
+ ('wiki.confidence.enabled', FALSE, 'Confidence taxonomy on wiki relations and pages'),
+ ('wiki.hot_cache.enabled', FALSE, 'KB-level recent-activity snapshot injected into agent system prompt'),
+ ('wiki.graph.insights.enabled', FALSE, 'Wiki graph insights panel (surprising connections, gaps, bridges)'),
+ ('wiki.graph.adamic_adar.enabled', FALSE, 'Adamic-Adar graph signal (additive to existing four signals)'),
+ ('wiki.graph.boundary.enabled', FALSE, 'Boundary score for surfacing dangling pages'),
+ ('wiki.relation.cache.enabled', TRUE, 'Persistent cache for wiki page-to-page relation computation')
+ON DUPLICATE KEY UPDATE description = VALUES(description);