From 5be315d76908b770e074e388923a9bf7a76c21b3 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Fri, 25 Sep 2026 10:41:10 +0800 Subject: [PATCH] A rule's definition has a history, and a conclusion names the version it was drawn under A business rule was one row edited in place, and a derivation pointed at the row. Change a threshold and the invalidated conclusions pointed at a rule that now said something else: the record axis kept "we concluded this, then it stopped holding" and lost "under which definition". Every edit that changes what a rule says now opens a version in attribute_rule_versions, a full snapshot (subject class, conclusion, join predicate, conditions) with a record time, and closes the previous one. Name, description and the enabled switch open nothing; whether the definition changed is decided by comparing the snapshot JSON, produced by one SQL expression the migration's backfill and the store share. A derivation names the version it was drawn under (derived_facts.attribute_rule_version_id). A conclusion that still stands after an edit keeps its row and moves to the new version, counted as `redefined`; the rows the edit invalidates keep pointing at the version they were drawn under. The proof carries the version number and its definition, the rules panel shows the version next to the name and opens the history, and GET /kbs/{id}/rules/{rule_id}/versions reads it for an integration, with the labels the ids resolve to today. Existing rules start at version 1 from the migration. Recorded as 0060. CURRENT_SCHEMA_VERSION is 76. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Wayland Yang --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-core/src/models.rs | 3 + crates/utopia-server/src/api/mod.rs | 5 + crates/utopia-server/src/api/rule_routes.rs | 11 + crates/utopia-store/src/business_rules.rs | 166 ++++++++++ crates/utopia-store/src/reasoning.rs | 45 ++- .../tests/a_rule_definition_has_a_history.rs | 298 ++++++++++++++++++ .../0060-a-rule-definition-has-a-history.md | 27 ++ docs/decisions/README.md | 2 + docs/design/rules.md | 6 + .../0076_a_rule_definition_has_a_history.sql | 56 ++++ web/src/api.ts | 28 ++ web/src/i18n/en.ts | 6 + web/src/i18n/zh.ts | 6 + web/src/pages/RulesPanel.tsx | 81 +++++ 15 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 crates/utopia-store/tests/a_rule_definition_has_a_history.rs create mode 100644 docs/decisions/0060-a-rule-definition-has-a-history.md create mode 100644 migrations/0076_a_rule_definition_has_a_history.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index f844b77ab..252f51859 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 75; +const CURRENT_SCHEMA_VERSION: u32 = 76; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 43efef659..59c2d3710 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -1338,6 +1338,9 @@ pub struct DerivedFactView { pub rule: String, /// 业务规则的名字。公理推的为 None——公理没有名字,`rule` 那一列就是它的全部身份 pub rule_name: Option, + /// 凭业务规则定义的哪一版推出的(0060),和那一版的定义本身。公理推的为 None + pub rule_version: Option, + pub rule_definition: Option, pub valid_from: Option>, pub valid_to: Option>, pub confidence: f32, diff --git a/crates/utopia-server/src/api/mod.rs b/crates/utopia-server/src/api/mod.rs index 6a96a5094..6bf0c1465 100644 --- a/crates/utopia-server/src/api/mod.rs +++ b/crates/utopia-server/src/api/mod.rs @@ -295,6 +295,11 @@ pub fn router(state: AppState, cfg: &AppConfig) -> Router { "/kbs/{id}/rules/{rule_id}/matches", get(rule_routes::matches), ) + // 定义史(0060):一条规则改过几次、每一版怎么说 + .route( + "/kbs/{id}/rules/{rule_id}/versions", + get(rule_routes::versions), + ) .route( "/kbs/{id}/ontology/type-resolution/preview", post(ontology_routes::type_resolution_preview), diff --git a/crates/utopia-server/src/api/rule_routes.rs b/crates/utopia-server/src/api/rule_routes.rs index 7e97eca8a..2cfb7aa93 100644 --- a/crates/utopia-server/src/api/rule_routes.rs +++ b/crates/utopia-server/src/api/rule_routes.rs @@ -183,6 +183,17 @@ pub async fn matches( Ok(Json(json!({ "matches": rows, "total": total }))) } +/// 一条规则的定义史(0060):每一版说了什么、从什么时候到什么时候、此刻凭它成立几条 +pub async fn versions( + State(state): State, + AuthUser(user): AuthUser, + Path((kb_id, rule_id)): Path<(Uuid, Uuid)>, +) -> ApiResult> { + require_kb(&state, &user, kb_id, Role::Viewer).await?; + let versions = utopia_store::business_rules::versions(&state.pool, kb_id, rule_id).await?; + Ok(Json(json!({ "versions": versions }))) +} + #[derive(Deserialize)] pub struct MatchQuery { #[serde(default)] diff --git a/crates/utopia-store/src/business_rules.rs b/crates/utopia-store/src/business_rules.rs index d63091130..0cb06db89 100644 --- a/crates/utopia-store/src/business_rules.rs +++ b/crates/utopia-store/src/business_rules.rs @@ -50,6 +50,8 @@ pub const IS_A: &str = "is_a"; #[derive(sqlx::FromRow)] struct RuleRow { id: Uuid, + /// 当前定义是第几版(0060)。迁移给每条老规则补了第 1 版,所以总有 + version: Option, name: String, description: String, subject_type_id: Uuid, @@ -193,6 +195,7 @@ pub async fn create( other => AppError::Db(other), })?; insert_conditions(&mut tx, id, conditions).await?; + record_version(&mut tx, kb_id, id).await?; tx.commit().await?; Ok(id) } @@ -300,10 +303,170 @@ pub async fn update( .await?; insert_conditions(&mut tx, rule_id, cs).await?; } + // 定义变了才开新版本;改名、改描述、开关不算(0060) + record_version(&mut tx, kb_id, rule_id).await?; tx.commit().await?; Ok(()) } +/// 定义的快照(0060):规则说了什么——主类、结论那几格、连接谓词、条件。名字、 +/// 描述和开关不在其中,它们是标签和开关,改了不等于规则换了说法。 +/// +/// **与迁移 0076 里回填第 1 版的表达式一字不差**:版本变没变是拿这份 JSON 比出来的, +/// 两处形状不一样就会把没改的规则也开成新版本 +const DEFINITION_SQL: &str = "jsonb_build_object( + 'subject_type_id', r.subject_type_id, + 'conclusion', r.conclusion, + 'conclude_type_id', r.conclude_type_id, + 'conclude_predicate_id', r.conclude_predicate_id, + 'conclude_value', r.conclude_value, + 'conclude_expr', r.conclude_expr, + 'join_predicate_id', r.join_predicate_id, + 'conditions', COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'group', c.group_seq, 'seq', c.seq, 'side', c.subject_side, + 'predicate_id', c.predicate_id, 'op', c.op, 'operand', c.operand) + ORDER BY c.group_seq, c.seq) + FROM attribute_rule_conditions c WHERE c.rule_id = r.id), '[]'::jsonb))"; + +/// 定义变了就开新版本:关掉当前的,序号加一。没变什么都不做。回当前版本的 id。 +/// +/// 在写规则的同一个事务里跑:定义和它的版本要么一起落,要么一起不落 +async fn record_version( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + kb_id: Uuid, + rule_id: Uuid, +) -> AppResult { + let now: serde_json::Value = sqlx::query_scalar(&format!( + "SELECT {DEFINITION_SQL} FROM attribute_rules r WHERE r.id = $1" + )) + .bind(rule_id) + .fetch_one(&mut **tx) + .await?; + let current: Option<(Uuid, i32, serde_json::Value)> = sqlx::query_as( + "SELECT id, seq, definition FROM attribute_rule_versions + WHERE rule_id = $1 AND superseded_at IS NULL", + ) + .bind(rule_id) + .fetch_optional(&mut **tx) + .await?; + let seq = match current { + Some((id, _, definition)) if definition == now => return Ok(id), + Some((id, seq, _)) => { + sqlx::query("UPDATE attribute_rule_versions SET superseded_at = now() WHERE id = $1") + .bind(id) + .execute(&mut **tx) + .await?; + seq + 1 + } + None => 1, + }; + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO attribute_rule_versions (id, kb_id, rule_id, seq, definition) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(id) + .bind(kb_id) + .bind(rule_id) + .bind(seq) + .bind(&now) + .execute(&mut **tx) + .await?; + Ok(id) +} + +/// 一版:id、序号、定义、记录时间起止、此刻凭它成立的结论条数 +type VersionRow = ( + Uuid, + i32, + serde_json::Value, + chrono::DateTime, + Option>, + i64, +); + +/// 一条规则的定义史,新的在前(0060)。每一版带它的记录时间起止、此刻凭它成立的 +/// 结论条数,和定义里提到的类与谓词的标签——历史里的 id 可能已经改名甚至删掉, +/// 标签按现在能查到的给,查不到的界面显示 id +pub async fn versions( + pool: &PgPool, + kb_id: Uuid, + rule_id: Uuid, +) -> AppResult> { + let rows: Vec = sqlx::query_as( + "SELECT v.id, v.seq, v.definition, v.recorded_at, v.superseded_at, + (SELECT count(*) FROM derived_facts d + WHERE d.attribute_rule_version_id = v.id AND d.invalidated_at IS NULL) + FROM attribute_rule_versions v + WHERE v.kb_id = $1 AND v.rule_id = $2 + ORDER BY v.seq DESC", + ) + .bind(kb_id) + .bind(rule_id) + .fetch_all(pool) + .await?; + if rows.is_empty() { + // 规则不存在,或不在这个库:两者对调用方都是 404 + let known: Option = + sqlx::query_scalar("SELECT id FROM attribute_rules WHERE id = $2 AND kb_id = $1") + .bind(kb_id) + .bind(rule_id) + .fetch_optional(pool) + .await?; + if known.is_none() { + return Err(AppError::NotFound); + } + } + let uuid_at = + |v: &serde_json::Value, k: &str| -> Option { v.get(k)?.as_str()?.parse().ok() }; + let mut classes: Vec = Vec::new(); + let mut predicates: Vec = Vec::new(); + for (_, _, d, _, _, _) in &rows { + classes.extend(uuid_at(d, "subject_type_id")); + classes.extend(uuid_at(d, "conclude_type_id")); + predicates.extend(uuid_at(d, "conclude_predicate_id")); + predicates.extend(uuid_at(d, "join_predicate_id")); + for c in d + .get("conditions") + .and_then(|c| c.as_array()) + .into_iter() + .flatten() + { + predicates.extend(uuid_at(c, "predicate_id")); + } + } + let mut labels: serde_json::Map = serde_json::Map::new(); + for (table, ids) in [("entity_types", classes), ("relation_types", predicates)] { + if ids.is_empty() { + continue; + } + let found: Vec<(Uuid, String)> = + sqlx::query_as(&format!("SELECT id, label FROM {table} WHERE id = ANY($1)")) + .bind(&ids) + .fetch_all(pool) + .await?; + for (id, label) in found { + labels.insert(id.to_string(), serde_json::Value::String(label)); + } + } + Ok(rows + .into_iter() + .map( + |(id, seq, definition, recorded_at, superseded_at, derived_count)| { + json!({ + "id": id, + "seq": seq, + "definition": definition, + "recorded_at": recorded_at, + "superseded_at": superseded_at, + "derived_count": derived_count, + "labels": labels, + }) + }, + ) + .collect()) +} + /// 删一条规则。它推出来的派生行随 `ON DELETE CASCADE` 一起走——**规则没了, /// 凭它得出的结论就没有依据了**,留着无从解释。 pub async fn delete(pool: &PgPool, kb_id: Uuid, rule_id: Uuid) -> AppResult<()> { @@ -327,6 +490,8 @@ pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult AppResult, attribute_rule_id: Option, + /// 业务规则推的:凭定义的哪一版(0060) + attribute_rule_version_id: Option, } /// JSON 值的规范化文本形态,只用来做键。 @@ -1356,6 +1360,9 @@ struct LoadedRule { subject_classes: Vec, /// 结论落在哪个谓词上:归类落 `is_a`,属性落它自己那个 conclude_predicate: Uuid, + /// 这一轮读的是规则定义的哪一版(0060):推出来的行记它,证明才说得出 + /// 「当时规则怎么说」。迁移给每条规则补了第 1 版,所以正常总有 + version: Option, } /// 编译出来的一批规则,外加接链要用的两样东西(0030)。 @@ -1382,6 +1389,8 @@ type RuleDefRow = ( Option, Option, Option, + // 当前版本的 id(0060) + Option, ); /// 取业务规则。条件形状不合法的规则**整条跳过而不是报错退出**——一条写坏的 @@ -1395,7 +1404,9 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { let rows: Vec = sqlx::query_as( "SELECT r.id, r.subject_type_id, r.conclusion, r.conclude_type_id, r.conclude_predicate_id, r.conclude_value, - r.conclude_expr, ct.iri, ct.key, r.join_predicate_id + r.conclude_expr, ct.iri, ct.key, r.join_predicate_id, + (SELECT v.id FROM attribute_rule_versions v + WHERE v.rule_id = r.id AND v.superseded_at IS NULL) AS version_id FROM attribute_rules r LEFT JOIN entity_types ct ON ct.id = r.conclude_type_id WHERE r.kb_id = $1 AND r.enabled @@ -1478,6 +1489,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { iri, key, join_predicate, + version, ) in rows { if broken.contains(&id) { @@ -1544,6 +1556,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { subject_types, subject_classes, conclude_predicate: predicate, + version, }); } Ok(LoadedRules { @@ -2132,6 +2145,7 @@ async fn resolve(pool: &PgPool, kb_id: Uuid) -> AppResult { premises: h.premises, rule_id: None, attribute_rule_id: Some(lr.rule.id), + attribute_rule_version_id: lr.version, }, ); dirty = true; @@ -2202,6 +2216,7 @@ async fn resolve(pool: &PgPool, kb_id: Uuid) -> AppResult { premises: h.premises, rule_id: None, attribute_rule_id: Some(lr.rule.id), + attribute_rule_version_id: lr.version, }, ); } @@ -2316,6 +2331,7 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult premises: d.premises.clone(), rule_id: Some(rule_id), attribute_rule_id: None, + attribute_rule_version_id: None, }); } for lr in &loaded.rules { @@ -2404,8 +2420,9 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_to, valid_from_precision, valid_to_precision, - confidence, rule_id, attribute_rule_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + confidence, rule_id, attribute_rule_id, + attribute_rule_version_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", ) .bind(id) .bind(kb_id) @@ -2420,11 +2437,29 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult .bind(conf) .bind(d.rule_id) .bind(d.attribute_rule_id) + .bind(d.attribute_rule_version_id) .execute(&mut *tx) .await?; report.inserted += 1; } + // 结论没变、定义变了:这一行现在是凭规则的新版本成立的(0060)。行留着—— + // 对账的键里没有版本,结论本身没变——版本换过来,证明才说得出它现在凭什么 + for (id, d) in &kept { + let Some(version) = d.attribute_rule_version_id else { + continue; + }; + let res = sqlx::query( + "UPDATE derived_facts SET attribute_rule_version_id = $2 + WHERE id = $1 AND attribute_rule_version_id IS DISTINCT FROM $2", + ) + .bind(id) + .bind(version) + .execute(&mut *tx) + .await?; + report.redefined += res.rows_affected() as usize; + } + // 结论没变、理由变了:**同一句话可以有第二条依据**(换了一条读数,或者换了 // 一组条件)。对账的键是主宾谓加区间,前提不在里面,所以那一行会带着上一轮 // 的证明留下来——链让这件事更容易撞上:站在它上面的那条前提可能刚刚作废。 @@ -2914,6 +2949,7 @@ async fn derived_one( r.label AS predicate, COALESCE(ru.kind, 'business') AS rule, ar.name AS rule_name, + v.seq AS rule_version, v.definition AS rule_definition, d.valid_from, d.valid_to, d.confidence, d.derived_at, COALESCE( (SELECT array_agg( @@ -2937,6 +2973,7 @@ async fn derived_one( JOIN relation_types r ON r.id = d.predicate_id LEFT JOIN rules ru ON ru.id = d.rule_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id + LEFT JOIN attribute_rule_versions v ON v.id = d.attribute_rule_version_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id WHERE d.kb_id = $1 AND d.id = $2", ) @@ -2979,6 +3016,7 @@ pub async fn derived_for_entity( r.label AS predicate, COALESCE(ru.kind, 'business') AS rule, ar.name AS rule_name, + v.seq AS rule_version, v.definition AS rule_definition, d.valid_from, d.valid_to, d.confidence, d.derived_at, COALESCE( (SELECT array_agg( @@ -3002,6 +3040,7 @@ pub async fn derived_for_entity( JOIN relation_types r ON r.id = d.predicate_id LEFT JOIN rules ru ON ru.id = d.rule_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id + LEFT JOIN attribute_rule_versions v ON v.id = d.attribute_rule_version_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id WHERE d.kb_id = $1 AND {derived_held} AND (d.subject_id = $2 OR d.object_id = $2) diff --git a/crates/utopia-store/tests/a_rule_definition_has_a_history.rs b/crates/utopia-store/tests/a_rule_definition_has_a_history.rs new file mode 100644 index 000000000..48d84fbc7 --- /dev/null +++ b/crates/utopia-store/tests/a_rule_definition_has_a_history.rs @@ -0,0 +1,298 @@ +//! A rule's definition has a history (0060, #912). +//! +//! Editing what a rule says opens a version and closes the previous one; renaming it +//! does not. A derivation names the version it was drawn under, a kept conclusion +//! moves to the new version, and the history endpoint reads all of it back with labels. + +use sqlx::PgPool; +use utopia_store::business_rules::{self, ConclusionInput, ConditionInput}; +use uuid::Uuid; + +struct Fixture { + org: Uuid, + kb: Uuid, + well: Uuid, + gas_well: Uuid, + depth: Uuid, + w1: Uuid, +} + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (well, gas_well, depth, w1) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'rule-history-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'rule-history-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'rule-history-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + for (id, key, label) in [ + (well, "well", "Well"), + (gas_well, "gas_well", "Gas-bearing well"), + ] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(kb) + .bind(key) + .bind(label) + .execute(pool) + .await?; + } + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind, datatype) + VALUES ($1, $2, 'depth', 'Depth', 'attribute', 'number')", + ) + .bind(depth) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, 'W-1')", + ) + .bind(w1) + .bind(kb) + .bind(well) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_value, + valid_from, valid_from_precision, confidence) + VALUES ($1, $2, $3, $4, $5, '2024-01-01T00:00:00Z', 'day', 0.9)", + ) + .bind(Uuid::now_v7()) + .bind(kb) + .bind(w1) + .bind(depth) + .bind(serde_json::json!({ "value": 3200.0 })) + .execute(pool) + .await?; + Ok(Fixture { + org, + kb, + well, + gas_well, + depth, + w1, + }) +} + +fn deeper_than(f: &Fixture, threshold: f64) -> [ConditionInput; 1] { + [ConditionInput { + group: 0, + predicate_id: f.depth, + op: "gt".into(), + operand: Some(serde_json::json!(threshold)), + side: "x".into(), + }] +} + +async fn versions_of(pool: &PgPool, rule: Uuid) -> anyhow::Result> { + Ok(sqlx::query_as( + "SELECT seq, superseded_at IS NULL FROM attribute_rule_versions + WHERE rule_id = $1 ORDER BY seq", + ) + .bind(rule) + .fetch_all(pool) + .await?) +} + +#[tokio::test] +async fn editing_what_a_rule_says_opens_a_version_and_a_conclusion_names_it() -> anyhow::Result<()> +{ + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let run = async { + let rule = business_rules::create( + &pool, + f.kb, + "deep well", + "", + f.well, + "typing", + Some(f.gas_well), + None, + None, + None, + None, + &deeper_than(&f, 3000.0), + ) + .await?; + assert_eq!( + versions_of(&pool, rule).await?, + vec![(1, true)], + "creating is version 1" + ); + + let report = utopia_store::reasoning::materialize(&pool, f.kb).await?; + assert_eq!(report.inserted, 1, "{report:?}"); + let v1: Uuid = sqlx::query_scalar( + "SELECT id FROM attribute_rule_versions WHERE rule_id = $1 AND seq = 1", + ) + .bind(rule) + .fetch_one(&pool) + .await?; + let (derived, under): (Uuid, Option) = sqlx::query_as( + "SELECT id, attribute_rule_version_id FROM derived_facts + WHERE kb_id = $1 AND invalidated_at IS NULL", + ) + .bind(f.kb) + .fetch_one(&pool) + .await?; + assert_eq!( + under, + Some(v1), + "the conclusion names the version it was drawn under" + ); + + // 改名、改描述、开关:定义没变,不开版本 + business_rules::update( + &pool, + f.kb, + rule, + Some("deep well (renamed)"), + Some("a note"), + Some(true), + None, + None, + ) + .await?; + assert_eq!( + versions_of(&pool, rule).await?, + vec![(1, true)], + "a label is not the definition" + ); + + // 阈值 3000 → 2500:定义变了,版本 2 开、版本 1 关。W-1(3200)仍然满足, + // 结论没变,行留着,改指版本 2 + business_rules::update( + &pool, + f.kb, + rule, + None, + None, + None, + Some(&deeper_than(&f, 2500.0)), + None, + ) + .await?; + assert_eq!(versions_of(&pool, rule).await?, vec![(1, false), (2, true)]); + let report = utopia_store::reasoning::materialize(&pool, f.kb).await?; + assert_eq!(report.inserted, 0, "{report:?}"); + assert_eq!(report.invalidated, 0, "{report:?}"); + assert_eq!( + report.redefined, 1, + "the kept row moved to the new version: {report:?}" + ); + let v2: Uuid = sqlx::query_scalar( + "SELECT id FROM attribute_rule_versions WHERE rule_id = $1 AND seq = 2", + ) + .bind(rule) + .fetch_one(&pool) + .await?; + let (same_row, under): (Uuid, Option) = sqlx::query_as( + "SELECT id, attribute_rule_version_id FROM derived_facts + WHERE kb_id = $1 AND invalidated_at IS NULL", + ) + .bind(f.kb) + .fetch_one(&pool) + .await?; + assert_eq!(same_row, derived, "the row is kept, not replaced"); + assert_eq!(under, Some(v2)); + + // 证明说得出凭哪一版、那一版怎么说 + let proof = utopia_store::reasoning::proof(&pool, f.kb, derived) + .await? + .expect("the conclusion stands"); + assert_eq!(proof.derived.rule_version, Some(2)); + let definition = proof + .derived + .rule_definition + .expect("the version's definition rides along"); + assert_eq!( + definition["conditions"][0]["operand"], + serde_json::json!(2500.0) + ); + + // 换个结论类:版本 3;结论换了,旧行作废、新行凭版本 3 + business_rules::update( + &pool, + f.kb, + rule, + None, + None, + None, + None, + Some(&ConclusionInput { + kind: "typing".into(), + type_id: Some(f.well), + predicate_id: None, + value: None, + expr: None, + join_predicate_id: None, + }), + ) + .await?; + assert_eq!( + versions_of(&pool, rule).await?, + vec![(1, false), (2, false), (3, true)] + ); + let report = utopia_store::reasoning::materialize(&pool, f.kb).await?; + assert_eq!((report.invalidated, report.inserted), (1, 1), "{report:?}"); + + // 历史:新的在前,带每一版此刻成立的条数和定义里提到的名字 + let history = business_rules::versions(&pool, f.kb, rule).await?; + assert_eq!(history.len(), 3); + assert_eq!(history[0]["seq"], 3); + assert_eq!(history[0]["derived_count"], 1); + assert_eq!(history[1]["seq"], 2); + assert_eq!( + history[1]["derived_count"], 0, + "the row that stood under v2 was withdrawn" + ); + assert!(history[1]["superseded_at"].is_string()); + assert!(history[0]["superseded_at"].is_null()); + assert_eq!( + history[2]["definition"]["conditions"][0]["operand"], + serde_json::json!(3000.0) + ); + assert_eq!(history[0]["labels"][f.depth.to_string()], "Depth"); + assert_eq!( + history[2]["labels"][f.gas_well.to_string()], + "Gas-bearing well" + ); + + // 不在这个库的规则:404,而不是空历史 + assert!(business_rules::versions(&pool, f.kb, Uuid::now_v7()) + .await + .is_err()); + let _ = f.w1; + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await?; + run +} diff --git a/docs/decisions/0060-a-rule-definition-has-a-history.md b/docs/decisions/0060-a-rule-definition-has-a-history.md new file mode 100644 index 000000000..d3888aeda --- /dev/null +++ b/docs/decisions/0060-a-rule-definition-has-a-history.md @@ -0,0 +1,27 @@ +# 0060 · A rule's definition has a history + +- **Status**: implemented 2026-09-25 in the PR for #912 · `attribute_rule_versions` (migration 0076), a derivation names the version it was drawn under, the proof and the rules panel read it, `GET /kbs/{id}/rules/{rule_id}/versions` · the export of business-rule bodies that [0020](0020-an-auditor-reads-it-without-us.md)'s revision deferred can now follow +- **Written**: 2026-09-25 (conventions in the [README](README.md)) +- **Related**: [0002](0002-reasoning-engine.md) made a derivation keep its record-time lifetime; [0019](0019-the-second-clock-can-be-rewound.md) is the record axis this record extends to rules; [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md) built the business rule as one row; [0030](0030-a-rule-may-read-what-a-rule-concluded.md) keeps a kept conclusion's row and reproves it; [0020](0020-an-auditor-reads-it-without-us.md) (revision 2026-09-25) declined to export rule bodies for the reason this record removes. From #912, out of #902. + +> A business rule was one row, and editing it was an `UPDATE`. A derivation pointed at the row. Change a threshold from 3000 to 3500 and materialize: the old conclusions are invalidated and the new ones land, which is right, but the invalidated rows point at a rule that now says 3500. The record axis kept "we concluded this, then it stopped holding" and lost "under which definition". The proof tree, the review card and the export could all say *which rule* and none could say *what it said at the time*. + +## What exists + +`attribute_rules` holds a business rule's subject class, conclusion, join predicate and, in `attribute_rule_conditions`, its conditions [0021, 0032, 0047]. `business_rules::update` rewrites the row and replaces the conditions. `derived_facts.attribute_rule_id` names the rule; `derived_at` and `invalidated_at` are the conclusion's record time [0002]. `materialize` keeps a still-standing conclusion's row and rewrites its premise links when the reason changed [0030]. `proof()` walks the premises; the export mints `…:rule:{id}` and, since 0020's revision, says the rule's family. + +## Decisions + +**1. A definition is append-only.** Every edit that changes what a rule says opens a version: a full snapshot of subject class, conclusion (kind and target), join predicate and conditions, with a record time; the previous version is closed with `superseded_at`, never rewritten. Name, description and the enabled switch are a label and a switch, not the definition, and do not open a version. Whether a definition changed is decided by comparing the snapshot JSON, produced by one SQL expression that the migration's backfill and the store share, so a no-op save opens nothing. + +**2. A derivation names the version it was drawn under.** `derived_facts.attribute_rule_version_id`, written at materialization from the version the run read. A conclusion that still stands after an edit keeps its row [0030] and moves to the new version, counted as `redefined` in the report: the row's identity is the conclusion, and what it now rests on is the current definition. The rows an edit invalidates keep pointing at the version they were drawn under, which is the sentence the record axis was missing. + +**3. The version is read wherever the rule is explained.** The proof carries the version number and its definition; the rules panel shows the version next to the name and opens the history: each version with its record interval, how many conclusions stand on it now, and its criterion and conclusion rendered the way the current one is, with the labels the ids resolve to today. `GET /kbs/{id}/rules/{rule_id}/versions` is the same reading for an integration. + +**4. Existing rules start at version 1.** The migration snapshots every rule as it stands, dated by its last edit, and points every existing derivation at that version. Nothing older is reconstructible and nothing pretends to be. + +## What this does not decide + +- **The export of a version's body.** This record makes it honest to export a business rule's conditions and expressions per version, which 0020's revision deferred; the vocabulary for operands, range bounds and expressions is still #902's second cut and is not chosen here. +- **Restoring an old version.** Editing back to an earlier definition opens a new version with the same content. A "revert" button would be sugar over that and can wait for someone to want it. +- **Versions of axiom declarations.** An axiom is a flag on a predicate, and a derivation already names the declaring predicate and kind; whether declarations need a history is a different question. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ff7c8a2d9..c792e92be 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -80,6 +80,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0052 | [Document content is a read contract over the retained ledger](0052-document-content-is-a-read-contract.md) | Proposed 2026-09-21 · implemented in #860 · two Viewer-level reads serve the retained originals the export already names by digest: `/documents/{id}/content[?version=N]` and `/documents/{id}/versions`; the handler locks the document and its ledger row through the blob read, purge answers 410, a ledger-referenced missing blob is a 500 invariant failure, a session or a scoped PAT may read, ingest tokens may not | 0053 | [A phrase decision records the inputs it considered](0053-a-phrase-decision-records-the-inputs-it-considered.md) | Implemented 2026-09-23 · a decision stores a fingerprint of the ancestor closures and admitted candidates it saw; stale means the fingerprint of the current inputs differs, which is what timestamps could not see (#807, #795): inheritance, parent edges, edits during the request; no-candidate and overflow become recorded outcomes; requeue reads live signatures only, so orphaned rows stop looping | 0054 | [A source may push statements in the open contract](0054-a-source-may-push-statements-in-the-open-contract.md) | Proposed 2026-09-23 · cut 1 in its PR · a `statements` source accepts the open extraction contract (`e`/`s`/`n`) verbatim on `POST /sources/{id}/statements` with the `api` push's identity, versions and tombstones; the payload is stored as one chunk and extraction parses it instead of prompting a model, then runs the unchanged path, so a pushed statement is an open statement and reaches the typed graph only through alignment; there is no slot for a property or class; an update marks earlier statements stale, it does not close them; tables stay on the mount (0036) +| 0060 | [A rule's definition has a history](0060-a-rule-definition-has-a-history.md) | Implemented 2026-09-25 (#912, migration 0076) · A business rule was edited in place and a derivation pointed at the row, so an invalidated conclusion pointed at a rule that now said something else. Every edit that changes what a rule says opens a **version**, a full snapshot with a record time; a derivation names the version it was drawn under, a kept conclusion moves to the new one, and the proof, the rules panel and a versions endpoint read the history. Name, description and the switch open nothing. Exporting rule bodies per version is now honest and stays #902's second cut | | | Record | Domain | Status | |---|---|---|---| @@ -137,6 +138,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0052 | [Document content is a read contract over the retained ledger](0052-document-content-is-a-read-contract.md) | sources | current | | 0053 | [A phrase decision records the inputs it considered](0053-a-phrase-decision-records-the-inputs-it-considered.md) | ontology | current | | 0054 | [A source may push statements in the open contract](0054-a-source-may-push-statements-in-the-open-contract.md) | sources | proposed | +| 0060 | [A rule's definition has a history](0060-a-rule-definition-has-a-history.md) | rules | current | The status word is whether a later record has overtaken this one; what is built is in the record's own status line. Domains are the files of [../design/](../design/README.md), where every record is dated and the status words are defined. diff --git a/docs/design/rules.md b/docs/design/rules.md index ec0a847d7..146e11d48 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -49,6 +49,12 @@ invalidates what is not in it [0030]. passage; derived edges are gold behind a toggle; blocked derivations are ghost edges; MCP has `list_rules` and `rule_matches`, read-only [0002, 0017, 0021]. +**A definition has a history.** Every edit that changes what a rule says opens a version (a full +snapshot with a record time) and closes the previous one; renaming or switching a rule off does +not. A derivation names the version it was drawn under, a kept conclusion moves to the new +version, and the proof, the rules panel's history and `GET /kbs/{id}/rules/{rule_id}/versions` +read it, so an invalidated conclusion still says what the rule said when it was drawn [0060]. + ## Why - **A reasoner amplifies defects**: 185 `part_of` facts became 828 under closure, with cycles from diff --git a/migrations/0076_a_rule_definition_has_a_history.sql b/migrations/0076_a_rule_definition_has_a_history.sql new file mode 100644 index 000000000..8b38786d4 --- /dev/null +++ b/migrations/0076_a_rule_definition_has_a_history.sql @@ -0,0 +1,56 @@ +-- 0060 · A rule's definition has a history (#912). +-- +-- A business rule was one row edited in place, and a derivation pointed at the row. +-- Change a threshold and the invalidated conclusions point at a rule that now says +-- something else: the record axis kept "we concluded this, then it stopped holding" +-- and lost "under which definition". Definitions are append-only from here: every +-- edit that changes what the rule says opens a version (a full snapshot), closes the +-- previous one with a record time, and a derivation names the version it was drawn +-- under. Name, description and the enabled switch are not the definition and do not +-- open a version. +CREATE TABLE attribute_rule_versions ( + id UUID PRIMARY KEY, + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + rule_id UUID NOT NULL REFERENCES attribute_rules(id) ON DELETE CASCADE, + seq INTEGER NOT NULL CHECK (seq >= 1), + -- 整份定义:主类、结论那几格、连接谓词、条件(组、序、侧、谓词、比较、操作数)。 + -- 形状与 `business_rules::DEFINITION_SQL` 一字不差——比较「变没变」靠的是它 + definition JSONB NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + superseded_at TIMESTAMPTZ, + UNIQUE (rule_id, seq) +); +-- 一条规则同一时刻只有一个没关的版本,当前版本一查即得 +CREATE UNIQUE INDEX attribute_rule_versions_current + ON attribute_rule_versions (rule_id) WHERE superseded_at IS NULL; + +-- 已有的规则各得版本 1,取它现在的样子,记录时间用最后一次编辑的时间 +INSERT INTO attribute_rule_versions (id, kb_id, rule_id, seq, definition, recorded_at) +SELECT gen_random_uuid(), r.kb_id, r.id, 1, + jsonb_build_object( + 'subject_type_id', r.subject_type_id, + 'conclusion', r.conclusion, + 'conclude_type_id', r.conclude_type_id, + 'conclude_predicate_id', r.conclude_predicate_id, + 'conclude_value', r.conclude_value, + 'conclude_expr', r.conclude_expr, + 'join_predicate_id', r.join_predicate_id, + 'conditions', COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'group', c.group_seq, 'seq', c.seq, 'side', c.subject_side, + 'predicate_id', c.predicate_id, 'op', c.op, 'operand', c.operand) + ORDER BY c.group_seq, c.seq) + FROM attribute_rule_conditions c WHERE c.rule_id = r.id), '[]'::jsonb)), + r.updated_at + FROM attribute_rules r; + +-- 派生指向它凭以推出的那个版本。规则没了版本跟着没,派生也早随规则一起走了 +ALTER TABLE derived_facts + ADD COLUMN attribute_rule_version_id UUID + REFERENCES attribute_rule_versions(id) ON DELETE CASCADE; +UPDATE derived_facts d + SET attribute_rule_version_id = v.id + FROM attribute_rule_versions v + WHERE v.rule_id = d.attribute_rule_id; +CREATE INDEX derived_facts_attribute_rule_version + ON derived_facts (attribute_rule_version_id) + WHERE attribute_rule_version_id IS NOT NULL; diff --git a/web/src/api.ts b/web/src/api.ts index 5fbc9da62..e007b08c4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -404,6 +404,29 @@ export interface BusinessRule extends Omit; } export interface DerivedFact { @@ -419,6 +442,8 @@ export interface DerivedFact { rule: "transitive" | "symmetric" | "inverse" | "sub_property" | "business"; /** 业务规则的名字。公理推的为 null——公理没有名字 */ rule_name?: string | null; + /** 凭业务规则定义的哪一版推出的。公理推的为 null */ + rule_version?: number | null; valid_from: string | null; valid_to: string | null; confidence: number; @@ -1977,6 +2002,9 @@ export const api = { request<{ matches: RuleMatch[]; total: number }>( `/api/v1/kbs/${kbId}/rules/${ruleId}/matches?page=${page}&per=${per}`, ), + /** 一条规则的定义史:改过几次、每一版怎么说 */ + ruleVersions: (kbId: string, ruleId: string) => + request<{ versions: RuleVersion[] }>(`/api/v1/kbs/${kbId}/rules/${ruleId}/versions`), deleteRule: (kbId: string, ruleId: string) => request<{ ok: boolean }>(`/api/v1/kbs/${kbId}/rules/${ruleId}`, { method: "DELETE", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 62a36be8b..28ea9ce5d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1359,6 +1359,12 @@ export const en = { ruleEditing: "Editing", ruleMatchesTitle: "What it marks", ruleMatchesEmpty: "Nothing right now.", + ruleVersion: (n: number) => `v${n}`, + ruleHistoryTitle: "How this rule has read", + ruleHistoryEmpty: "No history yet.", + ruleVersionCurrent: "current", + ruleVersionSince: (from: string, to: string | null) => (to ? `${from} to ${to}` : `since ${from}`), + ruleVersionStanding: (n: number) => (n === 1 ? "1 conclusion stands on it" : `${n} conclusions stand on it`), /* 前提要读成「凭什么」,所以用 because 起头而不是干列 */ ruleMatchBecause: (premises: string) => `because ${premises}`, /* 同一个实体会因为不同时段的读数出现好几次——不写出这一段就像重复了 */ diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1cab5d277..210bbcc1e 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1214,6 +1214,12 @@ export const zh: Strings = { ruleEditing: "正在编辑", ruleMatchesTitle: "它标住了谁", ruleMatchesEmpty: "此刻一个也没有。", + ruleVersion: (n: number) => `v${n}`, + ruleHistoryTitle: "这条规则改过几次、每一版怎么说", + ruleHistoryEmpty: "还没有历史。", + ruleVersionCurrent: "当前", + ruleVersionSince: (from: string, to: string | null) => (to ? `${from} 至 ${to}` : `自 ${from}`), + ruleVersionStanding: (n: number) => `此刻凭它成立 ${n} 条`, ruleMatchBecause: (premises: string) => `凭 ${premises}`, ruleMatchSpan: (from: string, to: string | null) => to ? `${from} 至 ${to}` : `${from} 起`, diff --git a/web/src/pages/RulesPanel.tsx b/web/src/pages/RulesPanel.tsx index 899093b3b..0ecccca39 100644 --- a/web/src/pages/RulesPanel.tsx +++ b/web/src/pages/RulesPanel.tsx @@ -251,6 +251,71 @@ function Matches({ kbId, ruleId }: { kbId: string; ruleId: string }) { ); } +/** 一条规则的定义史(0060)。每一版按现在的写法读出来:判据、结论、时段、此刻凭它成立几条 */ +function History({ kbId, ruleId, attributes }: { kbId: string; ruleId: string; attributes: RelationTypeView[] }) { + const q = useQuery({ + queryKey: ["ruleVersions", kbId, ruleId], + queryFn: () => api.ruleVersions(kbId, ruleId), + }); + const versions = q.data?.versions ?? []; + if (!versions.length) { + return

{S.ontology.ruleHistoryEmpty}

; + } + return ( +
+ {versions.map((v) => { + const label = (id: string | null) => (id ? (v.labels[id] ?? id) : ""); + // 借判据与结论两个渲染器:历史里的一版就是一条规则当时的样子 + const asRule = { + id: v.id, + name: "", + description: "", + enabled: true, + version: v.seq, + subject_type_id: v.definition.subject_type_id, + subject_label: label(v.definition.subject_type_id), + conclusion: v.definition.conclusion, + conclude_type_id: v.definition.conclude_type_id, + conclude_type_label: v.definition.conclude_type_id ? label(v.definition.conclude_type_id) : null, + conclude_predicate_id: v.definition.conclude_predicate_id, + conclude_predicate_label: v.definition.conclude_predicate_id ? label(v.definition.conclude_predicate_id) : null, + conclude_value: v.definition.conclude_value, + conclude_expr: v.definition.conclude_expr, + join_predicate_id: v.definition.join_predicate_id, + join_predicate_label: v.definition.join_predicate_id ? label(v.definition.join_predicate_id) : null, + conditions: v.definition.conditions.map((c) => ({ + group: c.group, + side: c.side, + predicate_id: c.predicate_id, + predicate_label: label(c.predicate_id), + op: c.op, + operand: c.operand, + })), + derived_count: v.derived_count, + capped: 0, + } as unknown as BusinessRule; + return ( +
+
+ {S.ontology.ruleVersion(v.seq)} + {!v.superseded_at && {S.ontology.ruleVersionCurrent}} + + {S.ontology.ruleVersionSince(v.recorded_at.slice(0, 10), v.superseded_at ? v.superseded_at.slice(0, 10) : null)} + + {S.ontology.ruleVersionStanding(v.derived_count)} +
+ +
+ {asRule.subject_label} → + +
+
+ ); + })} +
+ ); +} + export function RulesPanel({ kbId, focusId, @@ -281,6 +346,7 @@ export function RulesPanel({ const [doomed, setDoomed] = useState(null); /** 展开了哪条规则的命中列表。一次只展开一条——两份长列表并排读不了 */ const [opened, setOpened] = useState(null); + const [historyOf, setHistoryOf] = useState(null); const [filter, setFilter] = useState(""); const [dependenciesOf, setDependenciesOf] = useState(null); const [navigation, setNavigation] = useState<{ id: string } | null>(null); @@ -482,6 +548,10 @@ export function RulesPanel({
{r.name}
setDependenciesOf(r.id)}>{S.ontology.ruleDependencies} + {/* 第几版,点开是定义史:改了判据的规则,旧结论凭的是旧版 */} + setHistoryOf(r.id)}> + {S.ontology.ruleVersion(r.version ?? 1)} + {r.description && (
{r.description}
)} @@ -578,6 +648,17 @@ export function RulesPanel({ {opening && } + {/* 定义史:这一条改过几次、每一版怎么说 */} + !o && setHistoryOf(null)} + closeLabel={S.ui.close} + title={list.find((r) => r.id === historyOf)?.name ?? ""} + description={S.ontology.ruleHistoryTitle} + > + {historyOf && } + + {metadataRule && ( setMetadataRule(null)} onSaved={invalidate} />