diff --git a/crates/utopia-store/src/export.rs b/crates/utopia-store/src/export.rs index 095cc57b6..778c7b0e7 100644 --- a/crates/utopia-store/src/export.rs +++ b/crates/utopia-store/src/export.rs @@ -19,9 +19,11 @@ //! kb_id 与行本体**原子地一并选出**,校验在内存里跑。不能「先取一页、再去库里 //! 问一次」——第二次问的是另一个时刻的状态,留下的行早已不是它。 //! -//! 校验范围只覆盖**这份导出真正解析的引用**:会被铸成本库 IRI 的、会被按 id -//! 进本库词汇表查的。导出还没读的边(规则表自身、陈述属性、时间提及、 -//! 文档版本定位器等)不在这里管——它们随各自的导出面一起带上自己的校验。 +//! 体检范围覆盖 **0070 保护的每一条结构引用边**——不只这份导出真正解析的 +//! 那些。导出还没读的边(规则表自身、陈述属性、时间提及等)也在同一个快照里 +//! 先查:catalog 守卫(migration_0070_runs_under_any_search_path.rs)核对 +//! export_provenance_integrity.sql 的每条扫描分支都对应一条受保护的 +//! catalog 边,将来新加的受保护引用漏登记体检就直接红。 use chrono::{DateTime, Utc}; use sqlx::{Postgres, Transaction}; @@ -31,6 +33,10 @@ use uuid::Uuid; /// 一次取多少行。够大以免把往返次数拉满,够小以免一页就撑爆内存。 pub const PAGE: i64 = 500; +/// 出处体检的唯一一份 SQL:运行时就跑它,catalog 守卫读的也是它—— +/// 边集合只有这一处登记,没有第二份要手工对齐的清单 +const PREFLIGHT_SQL: &str = include_str!("export_provenance_integrity.sql"); + /// 出处链越界的一类引用。同库外键只认 id、不认库:A 库的 /// 行可以引用 B 库的对象,schema 什么都不拦。0070 的触发器挡新行;这里拦的是 /// **存量坏行**与绕过触发器写进来的行。 @@ -95,8 +101,10 @@ fn foreign(ref_kb: Option, kb_id: Uuid) -> bool { /// (`unexported`——合并掉的实体)。只报哪条边坏了、坏了几行——具体哪些行 /// 坏是库里的事,不进面向导出的报错 /// -/// 扫的边与导出面一一对应:只查这份导出会解析的引用(IRI 会铸出去的、 -/// 词汇表会按 id 查的)。**必须在导出用的那条事务里跑**(REPEATABLE READ): +/// 扫的边 = **0070 保护的全部结构引用边**(export_provenance_integrity.sql, +/// 每条分支带一条 `@edge`/`@filter` 标记给 catalog 守卫核对)——连导出尚未 +/// 序列化的边也算:一份账本上任何一条受保护的同库引用断了,这份导出都不可信, +/// 宁可整份拒。**必须在导出用的那条事务里跑**(REPEATABLE READ): /// 体检与每一页查询看的是同一个快照,先体检后换连接会在两个时刻之间 /// 漏掉刚提交的坏行 pub async fn provenance_integrity( @@ -109,157 +117,10 @@ pub async fn provenance_integrity( kind: String, rows: i64, } - let violations: Vec = sqlx::query_as( - "SELECT edge, kind, COUNT(*) AS rows FROM ( - -- 证据的段落:quote_origins 按它 JOIN chunks 取 origin——别库/悬空 - -- 的段落会让引文来源静默消失 - SELECT 'evidence.chunk'::text AS edge, 'cross_kb'::text AS kind, - c.kb_id IS DISTINCT FROM f.kb_id AS bad - FROM fact_evidence e - JOIN facts f ON f.id = e.fact_id - LEFT JOIN chunks c ON c.id = e.chunk_id - WHERE f.kb_id = $1 - UNION ALL - -- 证据的文档指针:铸成 prov:wasDerivedFrom 的文档 IRI - SELECT 'evidence.document', 'cross_kb', d.kb_id IS DISTINCT FROM f.kb_id - FROM fact_evidence e - JOIN facts f ON f.id = e.fact_id - LEFT JOIN documents d ON d.id = e.document_id - WHERE f.kb_id = $1 AND e.document_id IS NOT NULL - UNION ALL - -- 派生前提:铸成 prov:used 的事实/派生 IRI - SELECT 'derivation.premise_fact', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id - FROM fact_derivations fd - JOIN derived_facts d ON d.id = fd.derived_fact_id - LEFT JOIN facts p ON p.id = fd.premise_fact_id - WHERE d.kb_id = $1 AND fd.premise_fact_id IS NOT NULL - UNION ALL - SELECT 'derivation.premise_derived', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id - FROM fact_derivations fd - JOIN derived_facts d ON d.id = fd.derived_fact_id - LEFT JOIN derived_facts p ON p.id = fd.premise_derived_id - WHERE d.kb_id = $1 AND fd.premise_derived_id IS NOT NULL - UNION ALL - -- 边上的属性:类型进词汇表按 id 查(查不着静默丢),实体值铸 IRI - SELECT 'qualifier.type', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id - FROM fact_qualifiers q - JOIN facts f ON f.id = q.fact_id - LEFT JOIN relation_types r ON r.id = q.qualifier_type_id - WHERE f.kb_id = $1 - UNION ALL - SELECT 'qualifier.entity', 'cross_kb', e.kb_id IS DISTINCT FROM f.kb_id - FROM fact_qualifiers q - JOIN facts f ON f.id = q.fact_id - LEFT JOIN entities e ON e.id = q.entity_id - WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL - UNION ALL - SELECT 'qualifier.entity(merged)', 'unexported', TRUE - FROM fact_qualifiers q - JOIN facts f ON f.id = q.fact_id - JOIN entities e ON e.id = q.entity_id AND e.merged_into IS NOT NULL - WHERE f.kb_id = $1 - UNION ALL - -- 事实本体:主语铸 entity IRI,谓词进词汇表,supersedes 铸 fact IRI - SELECT 'fact.subject', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id - FROM facts f LEFT JOIN entities s ON s.id = f.subject_id - WHERE f.kb_id = $1 - UNION ALL - SELECT 'fact.subject(merged)', 'unexported', TRUE - FROM facts f JOIN entities s ON s.id = f.subject_id AND s.merged_into IS NOT NULL - WHERE f.kb_id = $1 - UNION ALL - SELECT 'fact.object', 'cross_kb', o.kb_id IS DISTINCT FROM f.kb_id - FROM facts f LEFT JOIN entities o ON o.id = f.object_id - WHERE f.kb_id = $1 AND f.object_id IS NOT NULL - UNION ALL - SELECT 'fact.object(merged)', 'unexported', TRUE - FROM facts f JOIN entities o ON o.id = f.object_id AND o.merged_into IS NOT NULL - WHERE f.kb_id = $1 - UNION ALL - SELECT 'fact.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id - FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id - WHERE f.kb_id = $1 AND f.predicate_id IS NOT NULL - UNION ALL - SELECT 'fact.supersedes', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id - FROM facts f LEFT JOIN facts s ON s.id = f.supersedes - WHERE f.kb_id = $1 AND f.supersedes IS NOT NULL - UNION ALL - -- 派生本体:规则 id 铸成 wasGeneratedBy 的 Activity IRI - SELECT 'derived.subject', 'cross_kb', s.kb_id IS DISTINCT FROM d.kb_id - FROM derived_facts d LEFT JOIN entities s ON s.id = d.subject_id - WHERE d.kb_id = $1 - UNION ALL - SELECT 'derived.subject(merged)', 'unexported', TRUE - FROM derived_facts d JOIN entities s ON s.id = d.subject_id AND s.merged_into IS NOT NULL - WHERE d.kb_id = $1 - UNION ALL - SELECT 'derived.object', 'cross_kb', o.kb_id IS DISTINCT FROM d.kb_id - FROM derived_facts d LEFT JOIN entities o ON o.id = d.object_id - WHERE d.kb_id = $1 AND d.object_id IS NOT NULL - UNION ALL - SELECT 'derived.object(merged)', 'unexported', TRUE - FROM derived_facts d JOIN entities o ON o.id = d.object_id AND o.merged_into IS NOT NULL - WHERE d.kb_id = $1 - UNION ALL - SELECT 'derived.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id - FROM derived_facts d LEFT JOIN relation_types r ON r.id = d.predicate_id - WHERE d.kb_id = $1 - UNION ALL - SELECT 'derived.rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id - FROM derived_facts d LEFT JOIN rules r ON r.id = d.rule_id - WHERE d.kb_id = $1 AND d.rule_id IS NOT NULL - UNION ALL - SELECT 'derived.attribute_rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id - FROM derived_facts d LEFT JOIN attribute_rules r ON r.id = d.attribute_rule_id - WHERE d.kb_id = $1 AND d.attribute_rule_id IS NOT NULL - UNION ALL - -- 实体的类进词汇表按 id 查 - SELECT 'entity.type', 'cross_kb', t.kb_id IS DISTINCT FROM e.kb_id - FROM entities e LEFT JOIN entity_types t ON t.id = e.type_id - WHERE e.kb_id = $1 AND e.type_id IS NOT NULL - UNION ALL - -- 类层级与互斥都进词汇表按 id 查 - SELECT 'class.parent', 'cross_kb', p.kb_id IS DISTINCT FROM c.kb_id - FROM entity_type_parents x - JOIN entity_types c ON c.id = x.child_id - LEFT JOIN entity_types p ON p.id = x.parent_id - WHERE c.kb_id = $1 - UNION ALL - SELECT 'class.disjoint', 'cross_kb', a.kb_id IS DISTINCT FROM dd.kb_id - FROM entity_type_disjoint dd - LEFT JOIN entity_types a ON a.id = dd.a_id - WHERE dd.kb_id = $1 - UNION ALL - SELECT 'class.disjoint', 'cross_kb', b.kb_id IS DISTINCT FROM dd.kb_id - FROM entity_type_disjoint dd - LEFT JOIN entity_types b ON b.id = dd.b_id - WHERE dd.kb_id = $1 - UNION ALL - -- domain/range 进词汇表按 id 查;inverse/sub_property 铸关系 IRI - SELECT 'relation.domain', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id - FROM relation_type_domains x - JOIN relation_types r ON r.id = x.relation_type_id - LEFT JOIN entity_types t ON t.id = x.entity_type_id - WHERE r.kb_id = $1 - UNION ALL - SELECT 'relation.range', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id - FROM relation_type_ranges x - JOIN relation_types r ON r.id = x.relation_type_id - LEFT JOIN entity_types t ON t.id = x.entity_type_id - WHERE r.kb_id = $1 - UNION ALL - SELECT 'relation.inverse', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id - FROM relation_types r LEFT JOIN relation_types t ON t.id = r.inverse_of - WHERE r.kb_id = $1 AND r.inverse_of IS NOT NULL - UNION ALL - SELECT 'relation.sub_property', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id - FROM relation_types r LEFT JOIN relation_types t ON t.id = r.sub_property_of - WHERE r.kb_id = $1 AND r.sub_property_of IS NOT NULL - ) refs WHERE bad GROUP BY edge, kind", - ) - .bind(kb_id) - .fetch_all(&mut **tx) - .await?; + let violations: Vec = sqlx::query_as(PREFLIGHT_SQL) + .bind(kb_id) + .fetch_all(&mut **tx) + .await?; let cross_kb: Vec = violations .iter() .filter(|v| v.kind == "cross_kb") diff --git a/crates/utopia-store/src/export_provenance_integrity.sql b/crates/utopia-store/src/export_provenance_integrity.sql new file mode 100644 index 000000000..5306dbe0a --- /dev/null +++ b/crates/utopia-store/src/export_provenance_integrity.sql @@ -0,0 +1,305 @@ +-- 导出前的出处体检(export::provenance_integrity 的唯一查询)。 +-- +-- 覆盖面 = 0070 保护的全部结构引用边:catalog 守卫 +-- (migration_0070_runs_under_any_search_path.rs)从 pg_catalog 数出每一条 +-- 受保护的列级引用,再回来核对这份文件里的扫描分支——一边漏登记都是 CI 红。 +-- 所以每条边不是「导出会解析才查」,而是「schema 保护了就必须在发出第一个 +-- 字节前查过」。 +-- +-- 机读约定(守卫按它比对,不许只改一边): +-- -- @edge src_table.src_col -> tgt_table.tgt_col +-- 紧随其后的 SELECT 分支是这条结构边的体检。结构身份按四元组核对, +-- 不是按报错 label——两条边可以共用一个 label(class.disjoint 的 +-- a_id/b_id),分支数必须等于 catalog 数出的边数。 +-- -- @filter label +-- 紧随其后的分支是「同库但不在导出集」的过滤完整性检查 +-- (merged_into 非空的实体):它护的是导出过滤口径,不是一条 +-- schema 引用边,与 @edge 分开登记。 +-- +-- 每个扫描分支恰好带一个标记;标记写错方向、分支漏标记、或 +-- catalog 边没有对应分支,守卫都会报出来。新增一条边时同时在 +-- malformed_rows_fail_every_exported_edge_closed 里种一行坏数据。 +-- 分支注释里不要写单引号、select 关键字、或 union all 合并字样—— +-- 守卫按字面量与分支分隔符解析,注释里的同名字样会被误当成结构。 +SELECT edge, kind, COUNT(*) AS rows FROM ( + -- @edge fact_evidence.chunk_id -> chunks.id + -- 证据的段落:quote_origins 按它 JOIN chunks 取 origin——别库/悬空 + -- 的段落会让引文来源静默消失 + SELECT 'evidence.chunk'::text AS edge, 'cross_kb'::text AS kind, + c.kb_id IS DISTINCT FROM f.kb_id AS bad + FROM fact_evidence e + JOIN facts f ON f.id = e.fact_id + LEFT JOIN chunks c ON c.id = e.chunk_id + WHERE f.kb_id = $1 + UNION ALL + -- @edge fact_evidence.document_id -> documents.id + -- 证据的文档指针:铸成 prov:wasDerivedFrom 的文档 IRI + SELECT 'evidence.document', 'cross_kb', d.kb_id IS DISTINCT FROM f.kb_id + FROM fact_evidence e + JOIN facts f ON f.id = e.fact_id + LEFT JOIN documents d ON d.id = e.document_id + WHERE f.kb_id = $1 AND e.document_id IS NOT NULL + UNION ALL + -- @edge chunks.document_id -> documents.id + -- 段落自己的文档归属:复合外键护写入,存量坏行在这里拦 + SELECT 'chunk.document', 'cross_kb', d.kb_id IS DISTINCT FROM c.kb_id + FROM chunks c + LEFT JOIN documents d ON d.id = c.document_id + WHERE c.kb_id = $1 + UNION ALL + -- @edge fact_derivations.premise_fact_id -> facts.id + -- 派生前提:铸成 prov:used 的事实/派生 IRI + SELECT 'derivation.premise_fact', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id + FROM fact_derivations fd + JOIN derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN facts p ON p.id = fd.premise_fact_id + WHERE d.kb_id = $1 AND fd.premise_fact_id IS NOT NULL + UNION ALL + -- @edge fact_derivations.premise_derived_id -> derived_facts.id + SELECT 'derivation.premise_derived', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id + FROM fact_derivations fd + JOIN derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN derived_facts p ON p.id = fd.premise_derived_id + WHERE d.kb_id = $1 AND fd.premise_derived_id IS NOT NULL + UNION ALL + -- @edge fact_qualifiers.qualifier_type_id -> relation_types.id + -- 边上的属性:类型进词汇表按 id 查(查不着静默丢),实体值铸 IRI + SELECT 'qualifier.type', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN relation_types r ON r.id = q.qualifier_type_id + WHERE f.kb_id = $1 + UNION ALL + -- @edge fact_qualifiers.entity_id -> entities.id + SELECT 'qualifier.entity', 'cross_kb', e.kb_id IS DISTINCT FROM f.kb_id + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL + UNION ALL + -- @filter qualifier.entity(merged) + SELECT 'qualifier.entity(merged)', 'unexported', TRUE + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + JOIN entities e ON e.id = q.entity_id AND e.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + -- @edge facts.subject_id -> entities.id + -- 事实本体:主语铸 entity IRI,谓词进词汇表,supersedes 铸 fact IRI + SELECT 'fact.subject', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN entities s ON s.id = f.subject_id + WHERE f.kb_id = $1 + UNION ALL + -- @filter fact.subject(merged) + SELECT 'fact.subject(merged)', 'unexported', TRUE + FROM facts f JOIN entities s ON s.id = f.subject_id AND s.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + -- @edge facts.object_id -> entities.id + SELECT 'fact.object', 'cross_kb', o.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN entities o ON o.id = f.object_id + WHERE f.kb_id = $1 AND f.object_id IS NOT NULL + UNION ALL + -- @filter fact.object(merged) + SELECT 'fact.object(merged)', 'unexported', TRUE + FROM facts f JOIN entities o ON o.id = f.object_id AND o.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + -- @edge facts.predicate_id -> relation_types.id + SELECT 'fact.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id + WHERE f.kb_id = $1 AND f.predicate_id IS NOT NULL + UNION ALL + -- @edge facts.supersedes -> facts.id + SELECT 'fact.supersedes', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN facts s ON s.id = f.supersedes + WHERE f.kb_id = $1 AND f.supersedes IS NOT NULL + UNION ALL + -- @edge facts.from_statement_id -> facts.id + -- 陈述来源是同表自指:与 supersedes 同一条判定,别库陈述不许当被引本体 + SELECT 'fact.from_statement', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN facts s ON s.id = f.from_statement_id + WHERE f.kb_id = $1 AND f.from_statement_id IS NOT NULL + UNION ALL + -- @edge derived_facts.subject_id -> entities.id + -- 派生本体:规则 id 铸成 wasGeneratedBy 的 Activity IRI + SELECT 'derived.subject', 'cross_kb', s.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN entities s ON s.id = d.subject_id + WHERE d.kb_id = $1 + UNION ALL + -- @filter derived.subject(merged) + SELECT 'derived.subject(merged)', 'unexported', TRUE + FROM derived_facts d JOIN entities s ON s.id = d.subject_id AND s.merged_into IS NOT NULL + WHERE d.kb_id = $1 + UNION ALL + -- @edge derived_facts.object_id -> entities.id + SELECT 'derived.object', 'cross_kb', o.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN entities o ON o.id = d.object_id + WHERE d.kb_id = $1 AND d.object_id IS NOT NULL + UNION ALL + -- @filter derived.object(merged) + SELECT 'derived.object(merged)', 'unexported', TRUE + FROM derived_facts d JOIN entities o ON o.id = d.object_id AND o.merged_into IS NOT NULL + WHERE d.kb_id = $1 + UNION ALL + -- @edge derived_facts.predicate_id -> relation_types.id + SELECT 'derived.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN relation_types r ON r.id = d.predicate_id + WHERE d.kb_id = $1 + UNION ALL + -- @edge derived_facts.rule_id -> rules.id + SELECT 'derived.rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN rules r ON r.id = d.rule_id + WHERE d.kb_id = $1 AND d.rule_id IS NOT NULL + UNION ALL + -- @edge derived_facts.attribute_rule_id -> attribute_rules.id + SELECT 'derived.attribute_rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN attribute_rules r ON r.id = d.attribute_rule_id + WHERE d.kb_id = $1 AND d.attribute_rule_id IS NOT NULL + UNION ALL + -- @edge entities.type_id -> entity_types.id + -- 实体的类进词汇表按 id 查 + SELECT 'entity.type', 'cross_kb', t.kb_id IS DISTINCT FROM e.kb_id + FROM entities e LEFT JOIN entity_types t ON t.id = e.type_id + WHERE e.kb_id = $1 AND e.type_id IS NOT NULL + UNION ALL + -- @edge entity_type_parents.parent_id -> entity_types.id + -- 类层级与互斥都进词汇表按 id 查 + SELECT 'class.parent', 'cross_kb', p.kb_id IS DISTINCT FROM c.kb_id + FROM entity_type_parents x + JOIN entity_types c ON c.id = x.child_id + LEFT JOIN entity_types p ON p.id = x.parent_id + WHERE c.kb_id = $1 + UNION ALL + -- @edge entity_type_disjoint.a_id -> entity_types.id + SELECT 'class.disjoint', 'cross_kb', a.kb_id IS DISTINCT FROM dd.kb_id + FROM entity_type_disjoint dd + LEFT JOIN entity_types a ON a.id = dd.a_id + WHERE dd.kb_id = $1 + UNION ALL + -- @edge entity_type_disjoint.b_id -> entity_types.id + -- a_id 与 b_id 是两条结构边、共用一个报错 label + SELECT 'class.disjoint', 'cross_kb', b.kb_id IS DISTINCT FROM dd.kb_id + FROM entity_type_disjoint dd + LEFT JOIN entity_types b ON b.id = dd.b_id + WHERE dd.kb_id = $1 + UNION ALL + -- @edge relation_type_domains.entity_type_id -> entity_types.id + -- domain/range 进词汇表按 id 查;inverse/sub_property 铸关系 IRI + SELECT 'relation.domain', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_domains x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN entity_types t ON t.id = x.entity_type_id + WHERE r.kb_id = $1 + UNION ALL + -- @edge relation_type_ranges.entity_type_id -> entity_types.id + SELECT 'relation.range', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_ranges x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN entity_types t ON t.id = x.entity_type_id + WHERE r.kb_id = $1 + UNION ALL + -- @edge relation_type_qualifiers.qualifier_type_id -> relation_types.id + -- 关系声明的边属性:归属按所属 relation 的库判 + SELECT 'relation.qualifier', 'cross_kb', q.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_qualifiers x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN relation_types q ON q.id = x.qualifier_type_id + WHERE r.kb_id = $1 + UNION ALL + -- @edge relation_types.inverse_of -> relation_types.id + SELECT 'relation.inverse', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_types r LEFT JOIN relation_types t ON t.id = r.inverse_of + WHERE r.kb_id = $1 AND r.inverse_of IS NOT NULL + UNION ALL + -- @edge relation_types.sub_property_of -> relation_types.id + SELECT 'relation.sub_property', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_types r LEFT JOIN relation_types t ON t.id = r.sub_property_of + WHERE r.kb_id = $1 AND r.sub_property_of IS NOT NULL + UNION ALL + -- @edge rules.predicate_id -> relation_types.id + -- 公理编在哪个谓词上是规则本体的语义 + SELECT 'rule.predicate', 'cross_kb', p.kb_id IS DISTINCT FROM u.kb_id + FROM rules u + LEFT JOIN relation_types p ON p.id = u.predicate_id + WHERE u.kb_id = $1 + UNION ALL + -- @edge attribute_rules.subject_type_id -> entity_types.id + SELECT 'arule.subject_type', 'cross_kb', t.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a + LEFT JOIN entity_types t ON t.id = a.subject_type_id + WHERE a.kb_id = $1 + UNION ALL + -- @edge attribute_rules.conclude_type_id -> entity_types.id + SELECT 'arule.conclude_type', 'cross_kb', t.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a + LEFT JOIN entity_types t ON t.id = a.conclude_type_id + WHERE a.kb_id = $1 AND a.conclude_type_id IS NOT NULL + UNION ALL + -- @edge attribute_rules.conclude_predicate_id -> relation_types.id + SELECT 'arule.conclude_predicate', 'cross_kb', p.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a + LEFT JOIN relation_types p ON p.id = a.conclude_predicate_id + WHERE a.kb_id = $1 AND a.conclude_predicate_id IS NOT NULL + UNION ALL + -- @edge attribute_rule_conditions.predicate_id -> relation_types.id + -- 条件行自己没有 kb 列:归属按所属规则的库判 + SELECT 'condition.predicate', 'cross_kb', p.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rule_conditions c + JOIN attribute_rules a ON a.id = c.rule_id + LEFT JOIN relation_types p ON p.id = c.predicate_id + WHERE a.kb_id = $1 + UNION ALL + -- @edge typed_fact_sources.statement_id -> facts.id + -- 来源边行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'factsource.statement', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM typed_fact_sources ts + JOIN facts f ON f.id = ts.fact_id + LEFT JOIN facts s ON s.id = ts.statement_id + WHERE f.kb_id = $1 + UNION ALL + -- @edge statement_qualifiers.entity_id -> entities.id + -- 开放陈述的属性行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'squalifier.entity', 'cross_kb', e.kb_id IS DISTINCT FROM f.kb_id + FROM statement_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL + UNION ALL + -- @edge time_mentions.fact_id -> facts.id + -- 时间提及以行自己的 kb 归属:指的事实与段落都必须同库 + SELECT 'timemention.fact', 'cross_kb', f.kb_id IS DISTINCT FROM t.kb_id + FROM time_mentions t + LEFT JOIN facts f ON f.id = t.fact_id + WHERE t.kb_id = $1 + UNION ALL + -- @edge time_mentions.chunk_id -> chunks.id + SELECT 'timemention.chunk', 'cross_kb', c.kb_id IS DISTINCT FROM t.kb_id + FROM time_mentions t + LEFT JOIN chunks c ON c.id = t.chunk_id + WHERE t.kb_id = $1 + UNION ALL + -- @edge type_bindings.type_id -> entity_types.id + SELECT 'binding.type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM type_bindings b + LEFT JOIN entity_types t ON t.id = b.type_id + WHERE b.kb_id = $1 AND b.type_id IS NOT NULL + UNION ALL + -- @edge phrase_bindings.subject_type_id -> entity_types.id + SELECT 'pbinding.subject_type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b + LEFT JOIN entity_types t ON t.id = b.subject_type_id + WHERE b.kb_id = $1 AND b.subject_type_id IS NOT NULL + UNION ALL + -- @edge phrase_bindings.object_type_id -> entity_types.id + SELECT 'pbinding.object_type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b + LEFT JOIN entity_types t ON t.id = b.object_type_id + WHERE b.kb_id = $1 AND b.object_type_id IS NOT NULL + UNION ALL + -- @edge phrase_bindings.relation_type_id -> relation_types.id + SELECT 'pbinding.relation', 'cross_kb', r.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b + LEFT JOIN relation_types r ON r.id = b.relation_type_id + WHERE b.kb_id = $1 AND b.relation_type_id IS NOT NULL +) refs WHERE bad GROUP BY edge, kind diff --git a/crates/utopia-store/tests/store/a_forward_reference_is_judged_at_commit.rs b/crates/utopia-store/tests/store/a_forward_reference_is_judged_at_commit.rs index 01e1f0a2a..d4091bc7b 100644 --- a/crates/utopia-store/tests/store/a_forward_reference_is_judged_at_commit.rs +++ b/crates/utopia-store/tests/store/a_forward_reference_is_judged_at_commit.rs @@ -10,8 +10,10 @@ //! 跨库的过不了,同库的前向链进得来; //! - **顺序语句**:递延外键同样把判断留到提交——同事务里「先插引用、 //! 后插目标」现在合法,目标始终不到的提交时被拦; -//! - **replica 会话**(pg_restore --disable-triggers 的形状):用户触发器 -//! 全关,但外键是内部约束触发器,照样查——同库判定在这一层也在场。 +//! - **replica 会话**(pg_restore --disable-triggers 的形状):这一层其实 +//! 没有墙——触发器全关,外键的约束触发器同样静默,同库判定不挡装载。 +//! 那样的存量坏行由导出预检与 §0 审计兜底;本文件只验正常事务的 +//! 提交边界。 use sqlx::{Acquire, PgPool}; use uuid::Uuid; diff --git a/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs b/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs index 15f36bc39..3f3c3590c 100644 --- a/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs +++ b/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs @@ -1,5 +1,5 @@ -//! 写入侧:导出会触碰的每条边都被 0070 的外键/触发器挡住;导出侧只复查 -//! 这份导出真正解析的那些引用——别库/悬空的行宁可整份拒导,也不许 +//! 写入侧:导出会触碰的每条边都被 0070 的外键/触发器挡住;导出侧体检 +//! 覆盖 0070 保护的**全部**结构引用边——别库/悬空的行宁可整份拒导,也不许 //! 伪造 IRI 或静默丢语义。落到别库的下场分两种: //! - 铸成本库 IRI 的引用(实体、事实、派生、文档、段落、规则)→ 伪造身份; //! - 进本库词汇表按 id 查的引用(谓词、属性类型、实体类型、父类)→ 静默消失。 @@ -18,6 +18,7 @@ struct Fixture { a: Uuid, b: Uuid, doc_a: Uuid, + doc_b: Uuid, chunk_a: Uuid, chunk_b: Uuid, ent_a: Uuid, @@ -185,6 +186,7 @@ async fn seed(pool: &PgPool) -> anyhow::Result { a, b, doc_a, + doc_b, chunk_a, chunk_b, ent_a, @@ -718,25 +720,207 @@ async fn malformed_rows_fail_every_exported_edge_closed() -> anyhow::Result<()> .bind(f.cls_b) .execute(&mut *tx) .await?; + + // —— 体检现在覆盖 0070 保护的全部结构边:剩下每条边也各造一行坏行 —— + // 证据的段落指针与冗余文档指针 + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id) VALUES ($1, $2)") + .bind(f.fact_a) + .bind(f.chunk_b) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id, document_id) VALUES ($1, $2, $3)") + .bind(f.fact_a) + .bind(f.chunk_a) + .bind(f.doc_b) + .execute(&mut *tx) + .await?; + // 段落挂在别库文档下 + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 9, 'x')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.doc_b) + .execute(&mut *tx) + .await?; + // 事实本体的主语/宾语/谓词 + sqlx::query( + "UPDATE facts SET subject_id = $2, object_id = $2, predicate_id = $3 WHERE id = $1", + ) + .bind(f.fact_a) + .bind(f.ent_b) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 派生本体的主语/宾语/谓词;另一条派生走业务规则——attribute_rule 别库 + sqlx::query( + "UPDATE derived_facts SET subject_id = $2, object_id = $2, predicate_id = $3 WHERE id = $1", + ) + .bind(f.der_a) + .bind(f.ent_b) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, attribute_rule_id) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.ent_a) + .bind(f.rel_a) + .bind(f.ent_a) + .bind(f.arule_b) + .execute(&mut *tx) + .await?; + // 类互斥的两个引用列是两条结构边、共用一个报错 label—— + // a_id 别库一行、b_id 别库一行(CHECK 不许 a_id = b_id) + sqlx::query("INSERT INTO entity_type_disjoint (kb_id, a_id, b_id) VALUES ($1, $2, $3)") + .bind(f.a) + .bind(f.cls_b) + .bind(f.cls_a) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO entity_type_disjoint (kb_id, a_id, b_id) VALUES ($1, $2, $3)") + .bind(f.a) + .bind(f.cls_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + // range 与关系声明的边属性 + sqlx::query( + "INSERT INTO relation_type_ranges (relation_type_id, entity_type_id) VALUES ($1, $2)", + ) + .bind(f.rel_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO relation_type_qualifiers (relation_type_id, qualifier_type_id) VALUES ($1, $2)", + ) + .bind(f.rel_a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 关系的同表自指 + sqlx::query("UPDATE relation_types SET inverse_of = $2, sub_property_of = $2 WHERE id = $1") + .bind(f.rel_a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 公理的谓词 + sqlx::query("UPDATE rules SET predicate_id = $2 WHERE id = $1") + .bind(f.rule_a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 业务规则的两个引用列、typing 结论的类、条件的谓词 + sqlx::query( + "UPDATE attribute_rules SET subject_type_id = $2, conclude_predicate_id = $3 WHERE id = $1", + ) + .bind(f.arule_a) + .bind(f.cls_b) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, conclude_type_id) + VALUES ($1, $2, 'ar-t', $3, 'typing', $4)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO attribute_rule_conditions (id, rule_id, seq, predicate_id, op) + VALUES ($1, $2, 0, $3, 'present')", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 时间提及指着别库事实(段落同库)——timemention.fact 这一条单独验 + sqlx::query( + "INSERT INTO time_mentions (id, kb_id, fact_id, chunk_id, text, char_start) + VALUES ($1, $2, $3, $4, '明年', 4)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.fact_b) + .bind(f.chunk_a) + .execute(&mut *tx) + .await?; + // 短语绑定的两个类型引用列 + sqlx::query( + "INSERT INTO phrase_bindings (id, kb_id, phrase, subject_type_id, status) + VALUES ($1, $2, 'runs-sub', $3, 'none')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO phrase_bindings (id, kb_id, phrase, object_type_id, status) + VALUES ($1, $2, 'runs-obj', $3, 'none')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; tx.commit().await?; drop(conn); let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.a).await; let msg = format!("{err:?}"); assert!(err.is_err(), "库 A 的体检必须拒导"); - // 体检只报这份导出真正解析的边:陈述来源、开放陈述属性、时间提及、 - // 类型/短语绑定这些行上面同样埋了坏行,但导出现在不读它们——它们随 - // 各自的导出面一起带上自己的校验 + // 体检覆盖 0070 保护的全部结构边——埋下去的每一类坏行都要被点名, + // 导出还没序列化的边(规则、绑定、时间提及、条件)也一样:受保护的 + // 同库引用断在账本上,这份导出就不可信。38 个报错 label 对应 39 条 + // 结构边(class.disjoint 的 a_id/b_id 共用一个 label) for edge in [ + "evidence.chunk", + "evidence.document", + "chunk.document", "derivation.premise_fact", "derivation.premise_derived", "qualifier.type", "qualifier.entity", + "fact.subject", + "fact.object", + "fact.predicate", "fact.supersedes", + "fact.from_statement", + "derived.subject", + "derived.object", + "derived.predicate", "derived.rule", + "derived.attribute_rule", "entity.type", "class.parent", + "class.disjoint", "relation.domain", + "relation.range", + "relation.qualifier", + "relation.inverse", + "relation.sub_property", + "rule.predicate", + "arule.subject_type", + "arule.conclude_type", + "arule.conclude_predicate", + "condition.predicate", + "factsource.statement", + "squalifier.entity", + "timemention.fact", + "timemention.chunk", + "binding.type", + "pbinding.subject_type", + "pbinding.object_type", + "pbinding.relation", ] { assert!(msg.contains(edge), "体检该报 {edge}: {msg}"); } diff --git a/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs b/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs index ba362e284..953c7d123 100644 --- a/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs +++ b/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs @@ -13,7 +13,7 @@ //! 不是跳过:地址都给了还说「没库」是假话,那个绿色等于这条检查没跑过。 use sqlx::{Acquire, PgPool}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use uuid::Uuid; fn admin_url() -> Option { @@ -483,6 +483,10 @@ struct Coverage { declarative: Vec, trigger_covered: Vec, non_scope: Vec, + /// 归进 declarative / trigger_covered 的边的结构身份 + /// (src_table, src_col, tgt_table, tgt_col)——体检核对按四元组, + /// 两条边共用一个报错 label 也得各算一条 + protected: BTreeSet, /// owner-derived 表上 catalog 有、登记没有的边 unknown: Vec, /// 登记/豁免/owner 声明在 catalog 里对不上的边 @@ -493,10 +497,16 @@ struct Coverage { unprotected_trigger: Vec, /// 责任面与已装机制对不上 surface_drift: Vec, + /// schema 保护了、但 preflight 没有对应扫描分支的边 + preflight_missing: Vec, + /// preflight 扫描声明的边在 catalog 保护集里对不上—— + /// 表/列改名后留下的腐掉分支 + stale_preflight: Vec, } impl Coverage { /// 完备 = 没有未归类的边、没有腐掉的登记、面与机制互证得上, + /// 每条受保护边都进了导出体检、体检里没有腐掉的分支, /// 且两类已覆盖边的数量与迁移记录的 26/13 一致。 fn assert_complete(&self) -> anyhow::Result<()> { let mut problems = String::new(); @@ -510,6 +520,8 @@ impl Coverage { dump("UNPROTECTED_DIRECT", &self.unprotected_direct); dump("UNPROTECTED_TRIGGER", &self.unprotected_trigger); dump("SURFACE_DRIFT", &self.surface_drift); + dump("PREFLIGHT_MISSING", &self.preflight_missing); + dump("STALE_PREFLIGHT", &self.stale_preflight); if self.declarative.len() != 26 { problems.push_str(&format!( " DECLARATIVE_EDGES = {} (expected 26)\n", @@ -532,6 +544,160 @@ impl Coverage { } } +/// preflight SQL 里一条结构边标记的四元组 +/// (src_table, src_col, tgt_table, tgt_col) +type EdgeKey = (String, String, String, String); + +/// 从 export_provenance_integrity.sql 解析出的覆盖面——测试读的就是 +/// 运行时执行的那一份(include_str! 同一条路径),不存在「登记给测试看、 +/// 跑的是另一份」的缝隙。edges = 每条 cross_kb 分支声明的结构边; +/// labels = 结构边 → 报错 label;filters = 「同库但不在导出集」的 +/// merged 检查——它护的是导出过滤口径,不是 schema 引用边 +#[derive(Default)] +struct PreflightSurface { + edges: BTreeSet, + labels: HashMap, + filters: BTreeSet, +} + +/// 一段 SQL 文本里的字符串字面量,按出现顺序——每个扫描分支的 +/// 头两个字面量就是它的报错 label 与 kind('cross_kb'/'unexported') +fn quoted_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c != '\'' { + continue; + } + let mut lit = String::new(); + for c2 in chars.by_ref() { + if c2 == '\'' { + break; + } + lit.push(c2); + } + out.push(lit); + } + out +} + +/// 'src_table.src_col -> tgt_table.tgt_col' → 四元组 +fn parse_edge_key(marker: &str) -> Option { + let (src, tgt) = marker.split_once("->")?; + let (st, sc) = src.trim().rsplit_once('.')?; + let (tt, tc) = tgt.trim().rsplit_once('.')?; + Some((st.into(), sc.into(), tt.into(), tc.into())) +} + +/// 把 preflight SQL 按 UNION ALL 切成扫描分支,逐条核对标记: +/// cross_kb 分支恰好一条 @edge、unexported 分支恰好一条与 label 同名的 +/// @filter;@edge 报出的表/列要在分支本体里真的出现——标记指错方向、 +/// 分支漏标记、一条边两条分支,都在这里炸出来 +fn preflight_surface() -> anyhow::Result { + const SRC: &str = include_str!("../../src/export_provenance_integrity.sql"); + let mut surface = PreflightSurface::default(); + let mut problems = String::new(); + for (i, chunk) in SRC.split("UNION ALL").enumerate() { + let mut edge_markers = Vec::new(); + let mut filter_markers = Vec::new(); + for line in chunk.lines() { + let l = line.trim(); + if let Some(m) = l.strip_prefix("-- @edge ") { + edge_markers.push(m.trim().to_string()); + } else if let Some(m) = l.strip_prefix("-- @filter ") { + filter_markers.push(m.trim().to_string()); + } + } + let literals = quoted_literals(chunk); + // 注释里写到 "UNION ALL" 也会切出一段——那种碎片没有 SELECT,跳过; + // 真分支缺字面量仍然是错 + let (Some(label), Some(kind)) = (literals.first(), literals.get(1)) else { + if chunk.contains("SELECT") { + problems.push_str(&format!(" branch {i}: no 'label'/'kind' literals found\n")); + } + continue; + }; + match kind.as_str() { + "cross_kb" => { + if edge_markers.len() != 1 || !filter_markers.is_empty() { + problems.push_str(&format!( + " {label}: a cross_kb branch must carry exactly one @edge marker\n" + )); + continue; + } + let Some(key) = parse_edge_key(&edge_markers[0]) else { + problems.push_str(&format!( + " {label}: malformed @edge marker '{}'\n", + edge_markers[0] + )); + continue; + }; + for needle in [&key.0, &key.1, &key.2] { + if !chunk.contains(needle.as_str()) { + problems.push_str(&format!( + " {label}: @edge names '{needle}' but the branch never mentions it\n" + )); + } + } + if !surface.edges.insert(key.clone()) { + problems.push_str(&format!( + " {label}: @edge '{}' duplicates an earlier branch\n", + edge_markers[0] + )); + } + surface.labels.insert(key, label.clone()); + } + "unexported" => { + if filter_markers.len() != 1 || !edge_markers.is_empty() { + problems.push_str(&format!( + " {label}: an unexported branch must carry exactly one @filter marker\n" + )); + continue; + } + if filter_markers[0] != *label { + problems.push_str(&format!( + " {label}: @filter names '{}' — the marker must equal the branch label\n", + filter_markers[0] + )); + } + surface.filters.insert(label.clone()); + } + other => problems.push_str(&format!(" {label}: unknown kind '{other}'\n")), + } + } + if problems.is_empty() { + Ok(surface) + } else { + Err(anyhow::anyhow!( + "preflight scan markers are inconsistent:\n{problems}" + )) + } +} + +/// 每个归类为保护边的 catalog 边,都必须在同一份 preflight SQL 里有 +/// 一条声明同样结构身份的扫描分支;反过来,preflight 声称的每条边也 +/// 必须仍是 catalog 保护集的一员——表/列改名后留着的旧分支照样红 +fn check_preflight(cov: &mut Coverage) -> anyhow::Result<()> { + let surface = preflight_surface()?; + for key in &surface.edges { + if !cov.protected.contains(key) { + cov.stale_preflight.push(format!( + "{}.{}\u{2192}{}.{} ('{}'): preflight watches an edge the catalog guard does not protect", + key.0, key.1, key.2, key.3, surface.labels[key] + )); + } + } + for key in &cov.protected { + if !surface.edges.contains(key) { + cov.preflight_missing.push(format!( + "{}.{}\u{2192}{}.{}: protected in the schema but absent from export preflight", + key.0, key.1, key.2, key.3 + )); + } + } + Ok(()) +} + /// 每条相关边归到恰好一类。相关 = 源表在责任面、引用列不是 kb_id /// 本身、目标表带 kb_id——kb_id→knowledge_bases 这类容器边被 /// `src_col != kb_id` 自然挡在面外,复合外键里的 kb_id→kb_id 那一腿 @@ -621,7 +787,10 @@ fn classify( } } else if let Some(tg) = registry.get(&(e.src_table.as_str(), e.src_col.as_str())) { match triggers.get(&(e.src_table.clone(), (*tg).to_string())) { - Some(cols) if cols.contains(&e.src_col) => cov.trigger_covered.push(label), + Some(cols) if cols.contains(&e.src_col) => { + cov.trigger_covered.push(label); + cov.protected.insert(edge_key(e)); + } Some(_) => cov .unprotected_trigger .push(format!("{label}: trigger {tg} does not watch this column")), @@ -653,12 +822,22 @@ fn classify( )); } else { cov.declarative.push(label); + cov.protected.insert(edge_key(e)); } } } cov } +fn edge_key(e: &RefEdge) -> EdgeKey { + ( + e.src_table.clone(), + e.src_col.clone(), + e.tgt_table.clone(), + e.tgt_col.clone(), + ) +} + async fn classify_reference_edges(pool: &PgPool) -> anyhow::Result { let (edges, kb_scoped, triggers, mechanism) = tokio::try_join!( catalog_edges(pool), @@ -666,7 +845,9 @@ async fn classify_reference_edges(pool: &PgPool) -> anyhow::Result { trigger_watch_lists(pool), mechanism_tables(pool), )?; - Ok(classify(&edges, &kb_scoped, &triggers, &mechanism)) + let mut cov = classify(&edges, &kb_scoped, &triggers, &mechanism); + check_preflight(&mut cov)?; + Ok(cov) } #[tokio::test] @@ -728,6 +909,107 @@ async fn a_new_reference_on_an_owner_derived_row_fails_the_guard() -> anyhow::Re .await } +/// 漂移探针 C:kb 自持行上新增一条**保护方式完全正确**的复合外键边—— +/// schema 侧挑不出毛病(归进 DECLARATIVE),但没人给它补导出体检。 +/// 只查 schema 的守卫会放行;这条边必须落进 PREFLIGHT_MISSING, +/// 报错里点名是哪条结构边 +#[tokio::test] +async fn a_protected_edge_missing_from_preflight_fails_the_guard() -> anyhow::Result<()> { + with_scratch("driftp", |pool| async move { + migration_70_under(&pool, "public").await?; + sqlx::query( + "ALTER TABLE public.time_mentions + ADD COLUMN probe_ref uuid, + ADD CONSTRAINT time_mentions_probe_same_kb + FOREIGN KEY (kb_id, probe_ref) REFERENCES public.relation_types (kb_id, id)", + ) + .execute(&pool) + .await?; + let cov = classify_reference_edges(&pool).await?; + assert!( + cov.declarative + .iter() + .any(|e| e.starts_with("time_mentions.probe_ref\u{2192}")), + "保护方式正确的探针边必须归进 DECLARATIVE——schema 侧没有问题: {cov:?}" + ); + assert!( + cov.preflight_missing + .iter() + .any(|e| e.contains("time_mentions.probe_ref")), + "漏登记的探针边必须落进 PREFLIGHT_MISSING: {cov:?}" + ); + let err = cov.assert_complete().unwrap_err().to_string(); + assert!( + err.contains("PREFLIGHT_MISSING") && err.contains("time_mentions.probe_ref"), + "报错必须点名缺体检的结构边: {err}" + ); + Ok(()) + }) + .await +} + +/// 比对器自身的敏感度:少一条已声明边 → MISSING;多一条 catalog 保护集 +/// 没有的 → STALE。守卫本身不能是「怎么都过」的摆设 +#[test] +fn the_preflight_check_notices_a_dropped_or_stray_edge() -> anyhow::Result<()> { + let surface = preflight_surface()?; + // 同一份文件两个方向各数一次:39 条结构边 + 5 条 merged 过滤检查 + assert_eq!(surface.edges.len(), 39, "preflight 必须覆盖全部保护边"); + assert_eq!( + surface.filters, + [ + "derived.object(merged)", + "derived.subject(merged)", + "fact.object(merged)", + "fact.subject(merged)", + "qualifier.entity(merged)", + ] + .into_iter() + .map(String::from) + .collect(), + "merged 过滤完整性检查是单独一类,不许静默增减" + ); + + // 保护集 = 体检声明集时两桶皆空 + let mut cov = Coverage { + protected: surface.edges.clone(), + ..Coverage::default() + }; + check_preflight(&mut cov)?; + assert!( + cov.preflight_missing.is_empty() && cov.stale_preflight.is_empty(), + "声明集与保护集一致时不许误报: {cov:?}" + ); + + // 漏一条:假定未来某条边从 SQL 里被删掉而 schema 仍在保护它 + let mut missing = Coverage { + protected: surface.edges.clone(), + ..Coverage::default() + }; + missing.protected.insert(( + "phantom_table".into(), + "phantom_col".into(), + "entities".into(), + "id".into(), + )); + check_preflight(&mut missing)?; + assert_eq!( + missing.preflight_missing.len(), + 1, + "catalog 有而 preflight 没有的边必须落进 MISSING" + ); + + // 反过来:preflight 还在看一条 catalog 不再保护的边 + let mut stale = Coverage::default(); + check_preflight(&mut stale)?; + assert_eq!( + stale.stale_preflight.len(), + surface.edges.len(), + "catalog 保护集之外的分支必须全部落进 STALE" + ); + Ok(()) +} + #[tokio::test] async fn the_migration_installs_under_an_empty_search_path() -> anyhow::Result<()> { with_scratch("empty", |pool| async move { diff --git a/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md b/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md index 6757b1748..22afd977a 100644 --- a/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md +++ b/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md @@ -1,8 +1,8 @@ # 0048 · Provenance references stay inside the knowledge base -- **Status**: Proposed 2026-09-20 · implemented in PR #832 (migration 0070), pending review +- **Status**: Implemented in PR #832 (migration 0070) - **Written**: 2026-09-20 (conventions in the [README](README.md)) -- **Related**: [0009](0009-no-type-is-a-type.md)'s "NULL means undecided" is why several edges below are nullable and therefore cannot lean on `MATCH FULL`; [0002](0002-reasoning-engine.md) owns the derivation model whose premise edges are covered here. Design question opened as issue #842. +- **Related**: [0009](0009-no-type-is-a-type.md)'s "NULL means undecided" is why several edges below are nullable and therefore cannot lean on `MATCH FULL`; [0002](0002-reasoning-engine.md) owns the derivation model whose premise edges are covered here. Mechanism choice resolved by PR #832; discussion tracked in issue #842. > The exporter writes `urn:utopia:kb:A:fact:{id}` and asserts the id belongs to knowledge base A. Nothing in the schema made that true — a foreign key proves the row exists, not which base it lives in. A cross-KB `supersedes` would mint a local IRI naming a foreign fact; a cross-KB `type_id` would resolve to nothing and the statement would silently lose its class. This record decides where the same-KB invariant is enforced, and with what. @@ -56,7 +56,7 @@ Thirty-nine reference edges are protected. The split is decided by a single ques - `pg_restore --disable-triggers` and any `session_replication_role = replica` load suppress *user* triggers wholesale. Declarative foreign keys are internal constraint triggers and are **not** suppressed — so the 26 declarative edges hold even in a replica-mode load, which is a second reason to prefer them wherever the schema can say them. The 13 trigger-covered edges admit the gap and are backstopped by the export-side `provenance_integrity` check and the §0 audit query, which doubles as a post-load audit. - The migration pins `search_path = pg_catalog` in every function body and qualifies every identifier `public.*`, because restore empties the session `search_path` and a hostile first schema must not redirect name resolution. `migration_0070_runs_under_any_search_path` installs the whole migration under a normal, an empty, and a decoy-first `search_path`. -- The coverage itself is guarded by a catalog-derived regression in the same test file: it enumerates every column-level reference inside the ledger surface from `pg_catalog` and fails when an edge resolves to no declared composite-FK, owner-derived-trigger, or explicit exclusion — so a reference column added later cannot silently slip past the invariant. +- The coverage itself is guarded by a catalog-derived regression in the same test file: it enumerates every column-level reference inside the ledger surface from `pg_catalog` and fails when an edge resolves to no declared composite-FK, owner-derived-trigger, or explicit exclusion — so a reference column added later cannot silently slip past the invariant. The same guard also reads the exact preflight scan the export runs (`export_provenance_integrity.sql`) and fails when a protected edge has no scan branch — or a scan branch no longer names a protected edge — so schema protection and export preflight cannot drift apart. ## The precondition scan @@ -84,7 +84,10 @@ The medians differ by −4% with fully overlapping ranges — **no measurable wr - **Application-layer checks.** The exporter already filters cross-KB references defensively; that is a backstop for readers, not an invariant for writers. The schema is the only layer every write path — present and future — passes through. - **`MATCH FULL` composite keys** were considered for nullable edges and rejected: `MATCH SIMPLE` (skip the check when the reference is NULL) preserves the existing nullable-edge semantics exactly; nothing here makes a NULL reference meaningful. +## Revisions + +- 2026-09-23 (#874): the original rationale said replica-mode loading silenced user triggers while declarative foreign-key enforcement stayed active. That was wrong — PostgreSQL foreign keys are enforced by constraint triggers, and `session_replication_role = replica` suppresses those checks as well, so replica mode does not distinguish the two mechanisms. The hybrid stands for the narrower reason recorded above: composite foreign keys are the native, smaller mechanism where the referencing row carries its own `kb_id`, while owner-derived rows cannot express that authority declaratively without a denormalized `kb_id` and a second invariant keeping it equal. The same correction is why the export-side scan covers all 39 edges rather than only the trigger-covered ones. Migration 0070's own header still carries the earlier wording — an applied SQLx migration is checksum-addressed and stays byte-stable, so the corrected operational rule lives here rather than in an edit to that file: replica-mode loading can bypass user triggers and FK constraint-trigger checks alike, and export preflight is the post-load backstop. + ## Open questions -- Whether maintainers prefer the hybrid split recorded here or uniform triggers (issue #842). The trigger machinery is additive — moving an edge from §1b to §1c is a one-line change in either direction, so the decision is cheap to revisit. - Whether the eight supporting `UNIQUE (kb_id, id)` indexes should be partial indexes over live rows instead; measured cost does not currently justify the extra subtlety. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 3ce0ed034..2631abb91 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -73,7 +73,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0045 | [A time mention is resolved against its document](0045-a-time-mention-is-resolved-against-its-document.md) | Accepted · cuts 1 and 2 built (#740): a document is dated from its own text, each mention is interpreted by the model and computed by code, upload time is used nowhere · cuts 3 and 4 (grades replace the confidence gate, re-resolution and the anchor queue) not built · a time expression is a mention with its words and place; the model returns shape, anchor, offset and granularity and code computes the interval; a document carries its own date, calendars and anchors across chunks, never its upload time; unresolved mentions wait for an anchor; timelines close on resolution grade instead of confidence | | 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | Decided, with the refused design kept. Asked for an app center: applications built on this knowledge, mounted, run in a sandbox, handed to a team. The answer is that the surface already exists — a personal token carries identity and scope, ten read tools serve chat and MCP from one place, `as_of` reaches every graph read, and a read returns `structuredContent` with stable ledger identities — so a coding agent builds on this base today in its own platform, its own language and its own sandbox. Refused here because the layer an app would read is being replaced under it (typed facts now come only from alignment), because 0016 closes open seams before cutting new ones, and because a catalog, an execution boundary and quotas are three other products. The shape is kept with the four gates it would have to hold (runs as the caller, egress only through a declared action, a declared clock, the existing queue) and the dead ends: a container runtime (withdrawn the day it was written — WeKnora's skills are human-written and assume a shell, and they pay for it), a Wasm component runtime (better on every axis including the determinism re-parse needs, still not built because the reason is priority), a service identity per app, an app as a saved conversation. Reopened by a named customer who needs a button inside the product, by the type layer settling, or after 0034 | | 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Proposed 2026-09-20 · nothing built · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the three caps in play are set by measurement in the PR that changes them | -| 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Proposed 2026-09-20 · implemented in PR #832 (migration 0070), pending review · a column foreign key proves the target exists, not that it is the same KB's — every reference an export can resolve gets a schema-level same-KB invariant: composite `(kb_id, ref)` foreign keys on the 26 edges whose row carries its own `kb_id` (same-table self-references deferred to commit), row triggers on the 13 whose kb authority is a parent row, `kb_id` immutability on every owned table, and a precondition scan that fails the migration closed on an already-cross-KB ledger · measured populate cost within noise; mechanism question open as issue #842 | +| 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Implemented in PR #832 (migration 0070) · a column foreign key proves the target exists, not that it is the same KB's — every reference an export can resolve gets a schema-level same-KB invariant: composite `(kb_id, ref)` foreign keys on the 26 edges whose row carries its own `kb_id` (same-table self-references deferred to commit), row triggers on the 13 whose kb authority is a parent row, `kb_id` immutability on every owned table, and a precondition scan that fails the migration closed on an already-cross-KB ledger · a catalog-derived guard keeps the 39 edges covered in the schema and, with #874, in export preflight · measured populate cost within noise; mechanism choice settled in #832 (discussion in issue #842) | | 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only | | 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | Proposed · durable execution identity and uncertain outcomes; no sender | | 0051 | [A human phrase decision carries its materialization work](0051-a-human-phrase-decision-carries-its-materialization-work.md) | Proposed · decision and materialization delivery; shared refactors and real regressions only |