diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
index 076d99de..cc92549b 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
@@ -49,6 +49,28 @@ public class WikiPageEntity {
/** Page type: entity / concept / source / synthesis */
private String pageType;
+ /**
+ * Structured pageType metadata (schema-validated fields) as a JSON object.
+ * Stored as a blob rather than exploded into per-field columns so each KB
+ * can define its own schema without altering the table. Written with the
+ * full page save path; partial column updates must avoid touching it.
+ */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
+ private String metadataJson;
+
+ /** Last metadata validation outcome: {@code ok} / {@code warning} / {@code invalid}. */
+ private String metadataValidationStatus;
+
+ /** Metadata validation warnings/errors as a JSON array (field, reason, source, rawValuePreview). */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
+ private String metadataValidationJson;
+
+ /** Template key used when generating this page, when applicable. */
+ private String templateKey;
+
+ /** Profile version in effect when the page was generated or last validated. */
+ private Integer profileVersion;
+
/** Purpose hint for LLM ingest routing */
private String purposeHint;
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java
new file mode 100644
index 00000000..c6fe4b58
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java
@@ -0,0 +1,54 @@
+package vip.mate.wiki.model;
+
+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;
+
+/**
+ * A KB-scoped pageType profile: the set of page types a knowledge base
+ * recognises, plus each type's field schema, per-stage LLM instructions and
+ * Markdown template, serialized into {@link #configJson}.
+ *
+ *
The built-in default profile is provided as a code constant and is NOT
+ * stored in this table, so {@link #kbId} is always non-null. A virtual
+ * generated column on the table enforces at most one enabled profile per KB.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@TableName("mate_wiki_page_type_profile")
+public class WikiPageTypeProfileEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** Owning knowledge base (never null — built-in default is not stored). */
+ private Long kbId;
+
+ /** Profile name, e.g. {@code default}, {@code regulation}. */
+ private String name;
+
+ /** Profile version, bumped on each saved edit. */
+ private Integer version;
+
+ /** Full pageType configuration as JSON. */
+ private String configJson;
+
+ /** {@code 1} = the active profile for the KB. */
+ private Integer enabled;
+
+ @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/wiki/repository/WikiPageTypeProfileMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageTypeProfileMapper.java
new file mode 100644
index 00000000..7dcfa43c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageTypeProfileMapper.java
@@ -0,0 +1,14 @@
+package vip.mate.wiki.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.wiki.model.WikiPageTypeProfileEntity;
+
+/**
+ * Mapper for {@link WikiPageTypeProfileEntity}.
+ *
+ * @author MateClaw Team
+ */
+@Mapper
+public interface WikiPageTypeProfileMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql b/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql
new file mode 100644
index 00000000..5f2d55b2
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql
@@ -0,0 +1,43 @@
+-- V134: KB-scoped pageType profile + structured page metadata columns.
+--
+-- A profile holds a KB's pageType definitions (field schema, per-stage LLM
+-- instructions, Markdown template) as config_json. The built-in default
+-- profile is NOT stored here — it lives as a code constant — so kb_id is
+-- NOT NULL and every stored row belongs to a concrete KB.
+--
+-- "At most one enabled profile per KB" is enforced at the DB level via a
+-- virtual generated column that yields kb_id only for the live-enabled
+-- subset (NULL otherwise) plus a plain UNIQUE constraint; NULLs are
+-- non-comparable under UNIQUE, so disabled/deleted rows coexist. This avoids
+-- a service-layer check-then-insert race across horizontally-scaled nodes.
+
+CREATE TABLE IF NOT EXISTS mate_wiki_page_type_profile (
+ id BIGINT NOT NULL,
+ kb_id BIGINT NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ version INT NOT NULL DEFAULT 1,
+ config_json CLOB NOT NULL,
+ enabled TINYINT NOT NULL DEFAULT 1,
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted INT NOT NULL DEFAULT 0,
+ -- Yields kb_id only for the live-enabled row; NULL otherwise. The UNIQUE
+ -- constraint below then permits at most one enabled profile per KB.
+ enabled_kb BIGINT GENERATED ALWAYS AS (
+ CASE WHEN enabled = 1 AND deleted = 0 THEN kb_id ELSE NULL END
+ ),
+ PRIMARY KEY (id),
+ CONSTRAINT uk_wiki_ptprofile_name UNIQUE (kb_id, name, deleted),
+ CONSTRAINT uk_wiki_ptprofile_enabled UNIQUE (enabled_kb)
+);
+CREATE INDEX IF NOT EXISTS idx_wiki_ptprofile_kb
+ ON mate_wiki_page_type_profile (kb_id, enabled, deleted);
+
+-- Structured page metadata: schema-validated pageType fields live in
+-- metadata_json (not exploded into columns); validation status/details and
+-- the generating profile/template are recorded alongside.
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_json CLOB;
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_validation_status VARCHAR(32);
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_validation_json CLOB;
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS template_key VARCHAR(128);
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS profile_version INT;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql
new file mode 100644
index 00000000..f4f018ab
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql
@@ -0,0 +1,63 @@
+-- V134: KB-scoped pageType profile + structured page metadata columns.
+-- See the H2 file for the design rationale. MySQL 8 uses a VIRTUAL generated
+-- column for the "one enabled profile per KB" constraint, and an
+-- INFORMATION_SCHEMA guard for each idempotent ADD COLUMN (MySQL has no
+-- ADD COLUMN IF NOT EXISTS).
+
+CREATE TABLE IF NOT EXISTS mate_wiki_page_type_profile (
+ id BIGINT NOT NULL,
+ kb_id BIGINT NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ version INT NOT NULL DEFAULT 1,
+ config_json LONGTEXT NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ deleted INT NOT NULL DEFAULT 0,
+ -- Yields kb_id only for the live-enabled row; NULL otherwise. InnoDB
+ -- ignores NULL keys for uniqueness, giving "at most one enabled per KB".
+ enabled_kb BIGINT
+ GENERATED ALWAYS AS (
+ CASE WHEN enabled = 1 AND deleted = 0 THEN kb_id ELSE NULL END
+ ) VIRTUAL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_wiki_ptprofile_name (kb_id, name, deleted),
+ UNIQUE KEY uk_wiki_ptprofile_enabled (enabled_kb),
+ KEY idx_wiki_ptprofile_kb (kb_id, enabled, deleted)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Structured page metadata columns (idempotent adds).
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'metadata_json');
+SET @ddl := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_json LONGTEXT', 'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'metadata_validation_status');
+SET @ddl := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_status VARCHAR(32)', 'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'metadata_validation_json');
+SET @ddl := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_json LONGTEXT', 'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'template_key');
+SET @ddl := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN template_key VARCHAR(128)', 'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'profile_version');
+SET @ddl := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN profile_version INT', 'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java
new file mode 100644
index 00000000..5d070b2a
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java
@@ -0,0 +1,85 @@
+package vip.mate.wiki.repository;
+
+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.WikiPageTypeProfileEntity;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Validates the DB-level invariants of the pageType profile table against H2:
+ * the V134 generated-column UNIQUE permits at most one enabled profile per KB,
+ * while disabled rows coexist freely.
+ */
+@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 WikiPageTypeProfileMapperE2ETest {
+
+ @Autowired
+ private WikiPageTypeProfileMapper mapper;
+
+ private WikiPageTypeProfileEntity profile(long kbId, String name, int enabled) {
+ WikiPageTypeProfileEntity p = new WikiPageTypeProfileEntity();
+ p.setKbId(kbId);
+ p.setName(name);
+ p.setVersion(1);
+ p.setConfigJson("{\"version\":1,\"pageTypes\":{}}");
+ p.setEnabled(enabled);
+ p.setCreateTime(LocalDateTime.now());
+ p.setUpdateTime(LocalDateTime.now());
+ return p;
+ }
+
+ @Test
+ void insertsAndReadsBack() {
+ WikiPageTypeProfileEntity p = profile(9001L, "default", 1);
+ mapper.insert(p);
+ assertNotNull(p.getId());
+ WikiPageTypeProfileEntity loaded = mapper.selectById(p.getId());
+ assertEquals("default", loaded.getName());
+ assertEquals(9001L, loaded.getKbId());
+ }
+
+ @Test
+ void secondEnabledProfileForSameKb_isRejected() {
+ mapper.insert(profile(9002L, "default", 1));
+ // A different name but also enabled for the same KB must violate the
+ // generated-column UNIQUE (one enabled profile per KB).
+ assertThrows(Exception.class, () -> mapper.insert(profile(9002L, "regulation", 1)));
+ }
+
+ @Test
+ void multipleDisabledProfilesForSameKb_coexist() {
+ mapper.insert(profile(9003L, "default", 1));
+ // enabled=0 rows yield NULL in the generated column and are exempt from
+ // the unique check, so several may coexist.
+ mapper.insert(profile(9003L, "draft-a", 0));
+ mapper.insert(profile(9003L, "draft-b", 0));
+ long count = mapper.selectCount(
+ com.baomidou.mybatisplus.core.toolkit.Wrappers
+ .lambdaQuery()
+ .eq(WikiPageTypeProfileEntity::getKbId, 9003L));
+ assertEquals(3, count);
+ }
+
+ @Test
+ void enabledProfilesInDifferentKbs_coexist() {
+ mapper.insert(profile(9101L, "default", 1));
+ mapper.insert(profile(9102L, "default", 1));
+ assertTrue(true); // no exception thrown — different KBs are independent
+ }
+}