From d3f7aefb5afbfbf78bf912eb6e5f4ed7ed155c18 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Thu, 20 Aug 2026 22:08:03 +0800 Subject: [PATCH 1/6] fix: recover rc3 workspace convergence state --- .../workspace_convergence_store.rs | 240 ++++++++++++++++-- docs/architecture/architecture-bible.md | 9 +- 2 files changed, 227 insertions(+), 22 deletions(-) diff --git a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs index b6ff7f5..0ac7d04 100644 --- a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs +++ b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use diesel::prelude::*; use diesel::sql_query; use diesel::sql_types::{BigInt, Binary, Text}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -44,6 +44,50 @@ struct UnversionedCurrentWorkspaceState { updated_at_ms: i64, } +/// Exact V2 payload layout written by Engine 1.1.0-rc.3. +/// +/// Postcard encodes struct fields positionally, so this persisted layout must +/// not be replaced with the evolving domain type. +#[derive(Deserialize)] +struct WorkspaceConvergenceStateV2 { + space_lineage: String, + own_instance: Option, + peer_history_relationships: + std::collections::BTreeMap, + membership_reconciliation: Option, + pending_applied_membership_effects: Vec, + pending_membership_decision_deliveries: Vec, + pending_admissions: std::collections::BTreeMap, + phase: WorkspacePhase, + failure_category: Option, + revision: u64, + removed: bool, + updated_at_ms: i64, + #[serde(default)] + migrated_from_pre_adr_020: bool, +} + +impl From for WorkspaceConvergenceState { + fn from(state: WorkspaceConvergenceStateV2) -> Self { + Self { + space_lineage: state.space_lineage, + own_instance: state.own_instance, + peer_history_relationships: state.peer_history_relationships, + membership_reconciliation: state.membership_reconciliation, + pending_applied_membership_effects: state.pending_applied_membership_effects, + pending_membership_decision_deliveries: state.pending_membership_decision_deliveries, + pending_membership_history_transfers: std::collections::BTreeMap::new(), + pending_admissions: state.pending_admissions, + phase: state.phase, + failure_category: state.failure_category, + revision: state.revision, + removed: state.removed, + updated_at_ms: state.updated_at_ms, + migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + } + } +} + #[derive(Deserialize)] struct DeviceTrustInitialWorkspaceState { space_lineage: String, @@ -307,7 +351,71 @@ const WORKSPACE_STATE_V3_STORAGE_VERSION: u16 = 3; #[derive(Debug, Clone, Serialize, Deserialize)] struct WorkspaceConvergenceStateV3 { storage_version: u16, - state: WorkspaceConvergenceState, + state: WorkspaceConvergenceStateV3Payload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkspaceConvergenceStateV3Payload { + space_lineage: String, + own_instance: Option, + peer_history_relationships: + std::collections::BTreeMap, + membership_reconciliation: Option, + pending_applied_membership_effects: Vec, + pending_membership_decision_deliveries: Vec, + pending_membership_history_transfers: std::collections::BTreeMap< + uc_core::DeviceId, + uc_core::membership::PendingMembershipHistoryTransferV2, + >, + pending_admissions: std::collections::BTreeMap, + phase: WorkspacePhase, + failure_category: Option, + revision: u64, + removed: bool, + updated_at_ms: i64, + migrated_from_pre_adr_020: bool, +} + +impl From for WorkspaceConvergenceStateV3Payload { + fn from(state: WorkspaceConvergenceState) -> Self { + Self { + space_lineage: state.space_lineage, + own_instance: state.own_instance, + peer_history_relationships: state.peer_history_relationships, + membership_reconciliation: state.membership_reconciliation, + pending_applied_membership_effects: state.pending_applied_membership_effects, + pending_membership_decision_deliveries: state.pending_membership_decision_deliveries, + pending_membership_history_transfers: state.pending_membership_history_transfers, + pending_admissions: state.pending_admissions, + phase: state.phase, + failure_category: state.failure_category, + revision: state.revision, + removed: state.removed, + updated_at_ms: state.updated_at_ms, + migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + } + } +} + +impl From for WorkspaceConvergenceState { + fn from(state: WorkspaceConvergenceStateV3Payload) -> Self { + Self { + space_lineage: state.space_lineage, + own_instance: state.own_instance, + peer_history_relationships: state.peer_history_relationships, + membership_reconciliation: state.membership_reconciliation, + pending_applied_membership_effects: state.pending_applied_membership_effects, + pending_membership_decision_deliveries: state.pending_membership_decision_deliveries, + pending_membership_history_transfers: state.pending_membership_history_transfers, + pending_admissions: state.pending_admissions, + phase: state.phase, + failure_category: state.failure_category, + revision: state.revision, + removed: state.removed, + updated_at_ms: state.updated_at_ms, + migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -371,13 +479,15 @@ fn open_workspace_plaintext( .map_err(|_| WorkspaceConvergenceRepositoryError::Corrupt) } -fn decode_current_workspace_payload( +fn decode_v2_workspace_payload( plaintext: &[u8], -) -> Result { +) -> Result { let encoded = plaintext .strip_prefix(WORKSPACE_STATE_V2_PREFIX) .unwrap_or(plaintext); - postcard::from_bytes(encoded).map_err(|_| WorkspaceConvergenceRepositoryError::Corrupt) + postcard::from_bytes::(encoded) + .map(WorkspaceConvergenceState::from) + .map_err(|_| WorkspaceConvergenceRepositoryError::Corrupt) } #[derive(QueryableByName)] @@ -487,8 +597,9 @@ fn open_v3_slot_payload( if stored.storage_version != WORKSPACE_STATE_V3_STORAGE_VERSION { return Err(WorkspaceConvergenceRepositoryError::Corrupt); } - validate_loaded_state(&stored.state, space_id, row.updated_at_ms)?; - Ok((stored.state, Sha256::digest(&plaintext).into())) + let state = WorkspaceConvergenceState::from(stored.state); + validate_loaded_state(&state, space_id, row.updated_at_ms)?; + Ok((state, Sha256::digest(&plaintext).into())) } fn load_v3_state_on( @@ -638,7 +749,7 @@ fn save_v3_state_on( let stored = WorkspaceConvergenceStateV3 { storage_version: WORKSPACE_STATE_V3_STORAGE_VERSION, - state: state.clone(), + state: state.clone().into(), }; let encrypted_slot = seal_prefixed_payload( master_key, @@ -781,7 +892,7 @@ fn load_state_on( &workspace_state_aad(space_id.as_ref()), )?; if plaintext.starts_with(WORKSPACE_STATE_V2_PREFIX) { - let state: WorkspaceConvergenceState = decode_current_workspace_payload(&plaintext)?; + let state = decode_v2_workspace_payload(&plaintext)?; validate_loaded_state(&state, space_id, row.updated_at_ms)?; let source = row.encrypted_payload; run_workspace_transaction(conn, |conn| { @@ -889,7 +1000,8 @@ mod tests { use uc_core::membership::{ AdmissionChangeFacts, MemberInstanceId, MembershipActivationBaselineV2, MembershipCredential, MembershipEvent, MembershipHistoryRelationship, MembershipOperation, - MembershipReconciliation, PendingAdmissionRecord, PendingMembershipHistoryTransferV2, + MembershipReconciliation, PendingAdmissionRecord, PendingAppliedMembershipEffect, + PendingMembershipDecisionDelivery, PendingMembershipHistoryTransferV2, VersionedMembershipHistory, WorkspaceConvergenceRepositoryPort, WorkspaceConvergenceState, WorkspacePhase, ED25519_SIGNATURE_ALGORITHM_V1, }; @@ -929,6 +1041,51 @@ mod tests { updated_at_ms: i64, } + #[derive(serde::Serialize)] + struct Rc3WorkspaceConvergenceState { + space_lineage: String, + own_instance: Option, + peer_history_relationships: BTreeMap, + membership_reconciliation: Option, + pending_applied_membership_effects: Vec, + pending_membership_decision_deliveries: Vec, + pending_admissions: + BTreeMap, + phase: WorkspacePhase, + failure_category: Option, + revision: u64, + removed: bool, + updated_at_ms: i64, + migrated_from_pre_adr_020: bool, + } + + #[derive(serde::Serialize)] + struct Rc4WorkspaceConvergenceStateV3 { + storage_version: u16, + state: WorkspaceConvergenceState, + } + + impl From for Rc3WorkspaceConvergenceState { + fn from(state: WorkspaceConvergenceState) -> Self { + Self { + space_lineage: state.space_lineage, + own_instance: state.own_instance, + peer_history_relationships: state.peer_history_relationships, + membership_reconciliation: state.membership_reconciliation, + pending_applied_membership_effects: state.pending_applied_membership_effects, + pending_membership_decision_deliveries: state + .pending_membership_decision_deliveries, + pending_admissions: state.pending_admissions, + phase: state.phase, + failure_category: state.failure_category, + revision: state.revision, + removed: state.removed, + updated_at_ms: state.updated_at_ms, + migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + } + } + } + fn insert_unversioned_payload( pool: &DbPool, value: &T, @@ -961,6 +1118,18 @@ mod tests { .unwrap(); } + fn rc3_v2_plaintext(state: &WorkspaceConvergenceState) -> Vec { + let rc3_state = Rc3WorkspaceConvergenceState::from(state.clone()); + let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); + plaintext.extend(postcard::to_stdvec(&rc3_state).unwrap()); + plaintext + } + + fn expected_rc3_state(mut state: WorkspaceConvergenceState) -> WorkspaceConvergenceState { + state.pending_membership_history_transfers.clear(); + state + } + fn session() -> InMemorySession { let session = InMemorySession::new(); session.set_master_key_for_space( @@ -1368,8 +1537,7 @@ mod tests { let (store, pool, _directory) = make_store(); let mut state = persisted_state(); state.space_lineage = "another-space".to_owned(); - let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); - plaintext.extend(postcard::to_stdvec(&state).unwrap()); + let plaintext = rc3_v2_plaintext(&state); insert_encrypted_plaintext(&pool, &plaintext, state.updated_at_ms); assert_eq!( @@ -1381,9 +1549,8 @@ mod tests { #[tokio::test] async fn v2_state_is_side_written_verified_and_guarded_before_v3_activation() { let (store, pool, _directory) = make_store(); - let state = persisted_state(); - let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); - plaintext.extend(postcard::to_stdvec(&state).unwrap()); + let state = expected_rc3_state(persisted_state()); + let plaintext = rc3_v2_plaintext(&state); insert_encrypted_plaintext(&pool, &plaintext, state.updated_at_ms); assert_eq!(store.load_state().await.unwrap(), Some(state.clone())); @@ -1418,12 +1585,44 @@ mod tests { assert_eq!(reopened.load_state().await.unwrap(), Some(state)); } + #[tokio::test] + async fn rc3_v2_state_is_recovered_and_migrated_to_v3() { + let (store, pool, _directory) = make_store(); + let expected = expected_rc3_state(persisted_state()); + let plaintext = rc3_v2_plaintext(&expected); + insert_encrypted_plaintext(&pool, &plaintext, expected.updated_at_ms); + + let encoded = plaintext + .strip_prefix(super::WORKSPACE_STATE_V2_PREFIX) + .unwrap(); + assert!(postcard::from_bytes::(encoded).is_err()); + + assert_eq!(store.load_state().await.unwrap(), Some(expected.clone())); + + let reopened = reopen_store(pool); + assert_eq!(reopened.load_state().await.unwrap(), Some(expected)); + } + + #[test] + fn rc4_v3_payload_remains_readable_by_the_fixed_v3_layout() { + let expected = persisted_state(); + let rc4 = Rc4WorkspaceConvergenceStateV3 { + storage_version: super::WORKSPACE_STATE_V3_STORAGE_VERSION, + state: expected.clone(), + }; + + let encoded = postcard::to_stdvec(&rc4).unwrap(); + let stored: super::WorkspaceConvergenceStateV3 = postcard::from_bytes(&encoded).unwrap(); + + assert_eq!(stored.storage_version, rc4.storage_version); + assert_eq!(WorkspaceConvergenceState::from(stored.state), expected); + } + #[tokio::test] async fn failed_v3_reopen_keeps_original_v2_ciphertext_and_no_active_pointer() { let (store, pool, _directory) = make_store(); - let state = persisted_state(); - let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); - plaintext.extend(postcard::to_stdvec(&state).unwrap()); + let state = expected_rc3_state(persisted_state()); + let plaintext = rc3_v2_plaintext(&state); insert_encrypted_plaintext(&pool, &plaintext, state.updated_at_ms); let original = { let mut connection = pool.get().unwrap(); @@ -1465,9 +1664,8 @@ mod tests { #[tokio::test] async fn reopen_finishes_v3_cleanup_without_changing_the_active_state() { let (store, pool, _directory) = make_store(); - let state = persisted_state(); - let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); - plaintext.extend(postcard::to_stdvec(&state).unwrap()); + let state = expected_rc3_state(persisted_state()); + let plaintext = rc3_v2_plaintext(&state); insert_encrypted_plaintext(&pool, &plaintext, state.updated_at_ms); assert_eq!(store.load_state().await.unwrap(), Some(state.clone())); diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index 4565015..c01c0eb 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -489,7 +489,12 @@ Engine、iOS、Android 和 HarmonyOS 共用同一版本契约,三个移动平 绑定内重算关系。旧查询、旧决定和旧提醒不进入正式产品构建;旧内部快照只允许在明确开启的诊断 构建中供多设备测试观察。共同设备组恢复仍未实施,当前结果明确返回不可用。 -工作空间收敛记录继续整块加密保存,密文内带明确格式标记。已有收敛状态行按其实际格式读取; +工作空间收敛记录继续整块加密保存,密文内带明确格式标记。每个已落盘版本使用独立且固定的持久化 +结构,不直接序列化会随业务演进的领域状态;新增字段必须启用新版本并提供显式转换。已有收敛状态行 +按其实际格式读取;`1.1.0-rc.3` 写入的 V2 记录按该版本的固定字段顺序恢复,新增的成员历史分页暂存 +初始化为空,再通过保留源密文、旁写目标、重新读取核验和最后切换的流程迁移到 V3。任何解析、身份 +核对、旁写或复读失败都保留 V2 原密文且不启用半成品,问题版本已发生过的失败读取不影响后续修复版 +重试恢复; v0.19.1 尚未保存收敛状态行,Engine 在首次打开当前格式时读取该档案已有的应用版本记录。只有记录中的 原版本低于当前版本且收敛状态行确实不存在时,才标记为等待旧空间共同升级,不能补造签名成员历史。 已有收敛状态始终优先;共同保护关系和当前签名成员历史都覆盖保留成员后才清除迁移标记,成员历史不得 @@ -943,6 +948,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 +- 2026-08-20:修复 `1.1.0-rc.3` 收敛状态升级到 V3 时因领域状态新增字段而被误报损坏的问题。V2 改为按该发布版的固定字段顺序读取并显式补齐新增分页暂存,V3 也使用独立固定结构,不再直接序列化可演进领域状态;迁移继续先保留源密文、旁写并复读核验后才切换,失败和重启均可从原记录重试。已安装问题版本但未彻底重置资料的用户升级修复版后可自动恢复。不改变对外接口。 + - 2026-08-20:全新安装首次启动确认没有既有设置后立即保存当前应用版本,避免完成设置后的第二次启动因版本游标仍为空而被误判为旧资料升级。既有资料缺少版本游标时仍进入旧资料独立化,并且只有独立化完整成功后才推进版本游标,失败时保留重试能力。不改变对外接口或持久化格式。 - 2026-08-20:旧资料升级隔离成功后立即推进应用版本游标,并在当前运行单元内标记为已执行;重新配对清除提醒并触发会话重建时,新的运行单元据此不得再次删除新成员。隔离失败不会推进游标,仍保留重试能力;重新配对提醒继续由独立的设置状态持久化,不因版本游标推进而提前消失。避免刚完成的重新配对在会话切换后被旧升级动作撤销。不改变对外接口或持久化格式。 From 4e04a0d382c733c569fd60826c85449fac4d2bb6 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Thu, 20 Aug 2026 23:27:55 +0800 Subject: [PATCH 2/6] chore: classify current peer scope failures --- .../convergence/projection/current_scope.rs | 127 +++++++++++++++--- docs/architecture/architecture-bible.md | 2 + 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/crates/uc-application/src/space/convergence/projection/current_scope.rs b/crates/uc-application/src/space/convergence/projection/current_scope.rs index 0107b78..e904429 100644 --- a/crates/uc-application/src/space/convergence/projection/current_scope.rs +++ b/crates/uc-application/src/space/convergence/projection/current_scope.rs @@ -25,8 +25,28 @@ impl WorkspaceConvergence { .admission_attempts .load_membership_history_v2() .await - .map_err(map_repository_error)? + .map_err(|error| { + let (category, mapped) = match error { + AdmissionAttemptRepositoryError::Locked => { + ("locked", CurrentWorkspacePeerScopeError::Locked) + } + AdmissionAttemptRepositoryError::Corrupt => { + ("corrupt", CurrentWorkspacePeerScopeError::Corrupt) + } + _ => ("unavailable", CurrentWorkspacePeerScopeError::Unavailable), + }; + tracing::warn!( + error_kind = "current_peer_scope_history_read", + error_category = category, + "[DEBUG-cps1] current peer scope diagnostic" + ); + mapped + })? else { + tracing::debug!( + error_kind = "current_peer_scope_v2_history_absent", + "[DEBUG-cps1] current peer scope diagnostic" + ); return Ok(None); }; let history = VersionedMembershipHistory::decode_persisted_v2( @@ -35,16 +55,44 @@ impl WorkspaceConvergence { ) .map_err(|error| match error { MembershipHistoryV2Error::UpgradeRequired => { + tracing::warn!( + error_kind = "current_peer_scope_history_decode", + error_category = "upgrade_required", + "[DEBUG-cps1] current peer scope diagnostic" + ); CurrentWorkspacePeerScopeError::Unavailable } - _ => CurrentWorkspacePeerScopeError::Corrupt, + _ => { + tracing::warn!( + error_kind = "current_peer_scope_history_decode", + error_category = "corrupt", + "[DEBUG-cps1] current peer scope diagnostic" + ); + CurrentWorkspacePeerScopeError::Corrupt + } })?; let local_join = self .deps .admission_attempts .project_current_local_join() .await - .map_err(map_repository_error)?; + .map_err(|error| { + let (category, mapped) = match error { + AdmissionAttemptRepositoryError::Locked => { + ("locked", CurrentWorkspacePeerScopeError::Locked) + } + AdmissionAttemptRepositoryError::Corrupt => { + ("corrupt", CurrentWorkspacePeerScopeError::Corrupt) + } + _ => ("unavailable", CurrentWorkspacePeerScopeError::Unavailable), + }; + tracing::warn!( + error_kind = "current_peer_scope_local_join_read", + error_category = category, + "[DEBUG-cps1] current peer scope diagnostic" + ); + mapped + })?; if history.lineage_id() != state.space_lineage { if let Some(join) = &local_join { if join.terminal_result.is_none() { @@ -88,15 +136,22 @@ impl WorkspaceConvergence { } } } + tracing::warn!( + error_kind = "current_peer_scope_lineage_mismatch", + local_join_present = local_join.is_some(), + "[DEBUG-cps1] current peer scope diagnostic" + ); return Err(CurrentWorkspacePeerScopeError::Corrupt); } - let members = self - .deps - .member_repo - .list() - .await - .map_err(|_| CurrentWorkspacePeerScopeError::Unavailable)?; + let members = self.deps.member_repo.list().await.map_err(|_| { + tracing::warn!( + error_kind = "current_peer_scope_member_roster_read", + error_category = "unavailable", + "[DEBUG-cps1] current peer scope diagnostic" + ); + CurrentWorkspacePeerScopeError::Unavailable + })?; let mut candidate_devices = members .into_iter() .map(|member| member.device_id) @@ -105,15 +160,22 @@ impl WorkspaceConvergence { candidate_devices.sort_by(|left, right| left.as_str().cmp(right.as_str())); candidate_devices.dedup(); + let active_member_count = history.active_members().len(); let active_devices = history .active_members() .iter() - .map(|member| { - history - .device_for_member(member, &candidate_devices) - .ok_or(CurrentWorkspacePeerScopeError::Unavailable) - }) - .collect::, _>>()?; + .filter_map(|member| history.device_for_member(member, &candidate_devices)) + .collect::>(); + if active_devices.len() != active_member_count { + tracing::warn!( + error_kind = "current_peer_scope_active_member_mapping_missing", + active_member_count, + candidate_device_count = candidate_devices.len(), + unresolved_member_count = active_member_count - active_devices.len(), + "[DEBUG-cps1] current peer scope diagnostic" + ); + return Err(CurrentWorkspacePeerScopeError::Unavailable); + } let local_join_does_not_block_current_history = local_join.is_none_or(|join| join.terminal_result.is_some()); let local_membership = if active_devices.contains(&self.deps.own_device) @@ -173,12 +235,14 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .as_ref() .filter(|history| history.applied_head().is_some()); let Some(history) = history else { - let members = self - .deps - .member_repo - .list() - .await - .map_err(|_| CurrentWorkspacePeerScopeError::Unavailable)?; + let members = self.deps.member_repo.list().await.map_err(|_| { + tracing::warn!( + error_kind = "current_peer_scope_legacy_member_roster_read", + error_category = "unavailable", + "[DEBUG-cps1] current peer scope diagnostic" + ); + CurrentWorkspacePeerScopeError::Unavailable + })?; let member_ids = members .iter() .map(|member| member.device_id) @@ -194,13 +258,32 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .await .map_err(|error| match error { uc_core::membership::SpaceProtectionError::Corrupted => { + tracing::warn!( + error_kind = "current_peer_scope_legacy_protection_read", + error_category = "corrupt", + "[DEBUG-cps1] current peer scope diagnostic" + ); CurrentWorkspacePeerScopeError::Corrupt } - _ => CurrentWorkspacePeerScopeError::Unavailable, + _ => { + tracing::warn!( + error_kind = "current_peer_scope_legacy_protection_read", + error_category = "unavailable", + "[DEBUG-cps1] current peer scope diagnostic" + ); + CurrentWorkspacePeerScopeError::Unavailable + } })?; if protection.mode != uc_core::membership::SpaceProtectionMode::Legacy && !state.migrated_from_pre_adr_020 { + tracing::warn!( + error_kind = "current_peer_scope_current_history_absent", + protection_mode = ?protection.mode, + migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020, + member_record_count = member_ids.len(), + "[DEBUG-cps1] current peer scope diagnostic" + ); return Err(CurrentWorkspacePeerScopeError::Unavailable); } let local_is_member = protection.mode diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index c01c0eb..2a6549a 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -946,6 +946,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> ## 文档维护记录 +- 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 + - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 - 2026-08-20:修复 `1.1.0-rc.3` 收敛状态升级到 V3 时因领域状态新增字段而被误报损坏的问题。V2 改为按该发布版的固定字段顺序读取并显式补齐新增分页暂存,V3 也使用独立固定结构,不再直接序列化可演进领域状态;迁移继续先保留源密文、旁写并复读核验后才切换,失败和重启均可从原记录重试。已安装问题版本但未彻底重置资料的用户升级修复版后可自动恢复。不改变对外接口。 From 8ce0c84e96dd0091df810195b229debc1db4dcac Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Thu, 20 Aug 2026 23:44:17 +0800 Subject: [PATCH 3/6] fix: recover rc3 membership migration provenance --- .../convergence/projection/current_scope.rs | 127 +++--------------- .../workspace_convergence_store.rs | 29 +++- docs/architecture/architecture-bible.md | 1 + 3 files changed, 51 insertions(+), 106 deletions(-) diff --git a/crates/uc-application/src/space/convergence/projection/current_scope.rs b/crates/uc-application/src/space/convergence/projection/current_scope.rs index e904429..0107b78 100644 --- a/crates/uc-application/src/space/convergence/projection/current_scope.rs +++ b/crates/uc-application/src/space/convergence/projection/current_scope.rs @@ -25,28 +25,8 @@ impl WorkspaceConvergence { .admission_attempts .load_membership_history_v2() .await - .map_err(|error| { - let (category, mapped) = match error { - AdmissionAttemptRepositoryError::Locked => { - ("locked", CurrentWorkspacePeerScopeError::Locked) - } - AdmissionAttemptRepositoryError::Corrupt => { - ("corrupt", CurrentWorkspacePeerScopeError::Corrupt) - } - _ => ("unavailable", CurrentWorkspacePeerScopeError::Unavailable), - }; - tracing::warn!( - error_kind = "current_peer_scope_history_read", - error_category = category, - "[DEBUG-cps1] current peer scope diagnostic" - ); - mapped - })? + .map_err(map_repository_error)? else { - tracing::debug!( - error_kind = "current_peer_scope_v2_history_absent", - "[DEBUG-cps1] current peer scope diagnostic" - ); return Ok(None); }; let history = VersionedMembershipHistory::decode_persisted_v2( @@ -55,44 +35,16 @@ impl WorkspaceConvergence { ) .map_err(|error| match error { MembershipHistoryV2Error::UpgradeRequired => { - tracing::warn!( - error_kind = "current_peer_scope_history_decode", - error_category = "upgrade_required", - "[DEBUG-cps1] current peer scope diagnostic" - ); CurrentWorkspacePeerScopeError::Unavailable } - _ => { - tracing::warn!( - error_kind = "current_peer_scope_history_decode", - error_category = "corrupt", - "[DEBUG-cps1] current peer scope diagnostic" - ); - CurrentWorkspacePeerScopeError::Corrupt - } + _ => CurrentWorkspacePeerScopeError::Corrupt, })?; let local_join = self .deps .admission_attempts .project_current_local_join() .await - .map_err(|error| { - let (category, mapped) = match error { - AdmissionAttemptRepositoryError::Locked => { - ("locked", CurrentWorkspacePeerScopeError::Locked) - } - AdmissionAttemptRepositoryError::Corrupt => { - ("corrupt", CurrentWorkspacePeerScopeError::Corrupt) - } - _ => ("unavailable", CurrentWorkspacePeerScopeError::Unavailable), - }; - tracing::warn!( - error_kind = "current_peer_scope_local_join_read", - error_category = category, - "[DEBUG-cps1] current peer scope diagnostic" - ); - mapped - })?; + .map_err(map_repository_error)?; if history.lineage_id() != state.space_lineage { if let Some(join) = &local_join { if join.terminal_result.is_none() { @@ -136,22 +88,15 @@ impl WorkspaceConvergence { } } } - tracing::warn!( - error_kind = "current_peer_scope_lineage_mismatch", - local_join_present = local_join.is_some(), - "[DEBUG-cps1] current peer scope diagnostic" - ); return Err(CurrentWorkspacePeerScopeError::Corrupt); } - let members = self.deps.member_repo.list().await.map_err(|_| { - tracing::warn!( - error_kind = "current_peer_scope_member_roster_read", - error_category = "unavailable", - "[DEBUG-cps1] current peer scope diagnostic" - ); - CurrentWorkspacePeerScopeError::Unavailable - })?; + let members = self + .deps + .member_repo + .list() + .await + .map_err(|_| CurrentWorkspacePeerScopeError::Unavailable)?; let mut candidate_devices = members .into_iter() .map(|member| member.device_id) @@ -160,22 +105,15 @@ impl WorkspaceConvergence { candidate_devices.sort_by(|left, right| left.as_str().cmp(right.as_str())); candidate_devices.dedup(); - let active_member_count = history.active_members().len(); let active_devices = history .active_members() .iter() - .filter_map(|member| history.device_for_member(member, &candidate_devices)) - .collect::>(); - if active_devices.len() != active_member_count { - tracing::warn!( - error_kind = "current_peer_scope_active_member_mapping_missing", - active_member_count, - candidate_device_count = candidate_devices.len(), - unresolved_member_count = active_member_count - active_devices.len(), - "[DEBUG-cps1] current peer scope diagnostic" - ); - return Err(CurrentWorkspacePeerScopeError::Unavailable); - } + .map(|member| { + history + .device_for_member(member, &candidate_devices) + .ok_or(CurrentWorkspacePeerScopeError::Unavailable) + }) + .collect::, _>>()?; let local_join_does_not_block_current_history = local_join.is_none_or(|join| join.terminal_result.is_some()); let local_membership = if active_devices.contains(&self.deps.own_device) @@ -235,14 +173,12 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .as_ref() .filter(|history| history.applied_head().is_some()); let Some(history) = history else { - let members = self.deps.member_repo.list().await.map_err(|_| { - tracing::warn!( - error_kind = "current_peer_scope_legacy_member_roster_read", - error_category = "unavailable", - "[DEBUG-cps1] current peer scope diagnostic" - ); - CurrentWorkspacePeerScopeError::Unavailable - })?; + let members = self + .deps + .member_repo + .list() + .await + .map_err(|_| CurrentWorkspacePeerScopeError::Unavailable)?; let member_ids = members .iter() .map(|member| member.device_id) @@ -258,32 +194,13 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .await .map_err(|error| match error { uc_core::membership::SpaceProtectionError::Corrupted => { - tracing::warn!( - error_kind = "current_peer_scope_legacy_protection_read", - error_category = "corrupt", - "[DEBUG-cps1] current peer scope diagnostic" - ); CurrentWorkspacePeerScopeError::Corrupt } - _ => { - tracing::warn!( - error_kind = "current_peer_scope_legacy_protection_read", - error_category = "unavailable", - "[DEBUG-cps1] current peer scope diagnostic" - ); - CurrentWorkspacePeerScopeError::Unavailable - } + _ => CurrentWorkspacePeerScopeError::Unavailable, })?; if protection.mode != uc_core::membership::SpaceProtectionMode::Legacy && !state.migrated_from_pre_adr_020 { - tracing::warn!( - error_kind = "current_peer_scope_current_history_absent", - protection_mode = ?protection.mode, - migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020, - member_record_count = member_ids.len(), - "[DEBUG-cps1] current peer scope diagnostic" - ); return Err(CurrentWorkspacePeerScopeError::Unavailable); } let local_is_member = protection.mode diff --git a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs index 0ac7d04..c1383e2 100644 --- a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs +++ b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs @@ -69,6 +69,11 @@ struct WorkspaceConvergenceStateV2 { impl From for WorkspaceConvergenceState { fn from(state: WorkspaceConvergenceStateV2) -> Self { + let migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020 + || state + .membership_reconciliation + .as_ref() + .is_none_or(|history| history.applied_head().is_none()); Self { space_lineage: state.space_lineage, own_instance: state.own_instance, @@ -83,7 +88,7 @@ impl From for WorkspaceConvergenceState { revision: state.revision, removed: state.removed, updated_at_ms: state.updated_at_ms, - migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + migrated_from_pre_adr_020, } } } @@ -1127,6 +1132,11 @@ mod tests { fn expected_rc3_state(mut state: WorkspaceConvergenceState) -> WorkspaceConvergenceState { state.pending_membership_history_transfers.clear(); + state.migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020 + || state + .membership_reconciliation + .as_ref() + .is_none_or(|history| history.applied_head().is_none()); state } @@ -1603,6 +1613,23 @@ mod tests { assert_eq!(reopened.load_state().await.unwrap(), Some(expected)); } + #[tokio::test] + async fn rc3_v2_state_without_current_history_recovers_legacy_migration_provenance() { + let (store, pool, _directory) = make_store(); + let mut expected = expected_rc3_state(persisted_state()); + expected.membership_reconciliation = None; + expected.migrated_from_pre_adr_020 = false; + let plaintext = rc3_v2_plaintext(&expected); + insert_encrypted_plaintext(&pool, &plaintext, expected.updated_at_ms); + + let loaded = store.load_state().await.unwrap().unwrap(); + + assert!(loaded.membership_reconciliation.is_none()); + assert!(loaded.migrated_from_pre_adr_020); + let reopened = reopen_store(pool); + assert_eq!(reopened.load_state().await.unwrap(), Some(loaded)); + } + #[test] fn rc4_v3_payload_remains_readable_by_the_fixed_v3_layout() { let expected = persisted_state(); diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index 2a6549a..2470698 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -1252,6 +1252,7 @@ node scripts/release/verify-release-bundle.mjs <产物目录> | 2026-08-03 | 首次建立架构总览,并固化 Agent 同步维护要求 | 建立当前架构基线 | | 2026-08-19 | 旧资料独立化评审修复 | 独立化流程在清理前保存唯一目标并在中断后复用;会话恢复与手动解锁共用同一后处理,产品可通过事件或状态查询恢复重新配对提示 | | 2026-08-19 | 旧资料升级识别收口 | Engine 启动与产品查询共用应用层升级判断;没有版本游标但已经完成初始化的旧资料会进入独立化,全新安装不会误触发。独立化是否需要的版本规则只保留在升级状态中。 | +| 2026-08-20 | rc.3 空间升级来源恢复 | 读取 rc.3 加密空间状态时,缺少已生效成员历史即恢复为旧资料迁移来源,使既有受控迁移流程可以补齐成员基线;不把设备记录直接提升为长期成员事实。 | ## 相关文档 From f287d32715fbdac7655db5f8642e3301182ced68 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 21 Aug 2026 00:07:51 +0800 Subject: [PATCH 4/6] fix: recover previously migrated rc3 workspaces --- .../workspace_convergence_store.rs | 47 ++++++++++++++++++- docs/architecture/architecture-bible.md | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs index c1383e2..d46a203 100644 --- a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs +++ b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs @@ -404,6 +404,14 @@ impl From for WorkspaceConvergenceStateV3Payload { impl From for WorkspaceConvergenceState { fn from(state: WorkspaceConvergenceStateV3Payload) -> Self { + let migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020 + || (state.phase == WorkspacePhase::Complete + && state.own_instance.is_some() + && !state.peer_history_relationships.is_empty() + && state + .membership_reconciliation + .as_ref() + .is_none_or(|history| history.applied_head().is_none())); Self { space_lineage: state.space_lineage, own_instance: state.own_instance, @@ -418,7 +426,7 @@ impl From for WorkspaceConvergenceState { revision: state.revision, removed: state.removed, updated_at_ms: state.updated_at_ms, - migrated_from_pre_adr_020: state.migrated_from_pre_adr_020, + migrated_from_pre_adr_020, } } } @@ -1645,6 +1653,43 @@ mod tests { assert_eq!(WorkspaceConvergenceState::from(stored.state), expected); } + #[test] + fn v3_state_previously_migrated_without_provenance_recovers_legacy_origin() { + let mut affected = persisted_state(); + affected.own_instance = Some(MemberInstanceId::from_bytes([0x24; 32])); + affected.phase = WorkspacePhase::Complete; + affected.membership_reconciliation = None; + affected.migrated_from_pre_adr_020 = false; + let stored = super::WorkspaceConvergenceStateV3Payload::from(affected); + + let recovered = WorkspaceConvergenceState::from(stored); + + assert!(recovered.migrated_from_pre_adr_020); + } + + #[test] + fn v3_state_without_complete_legacy_evidence_remains_fail_closed() { + let mut incomplete = persisted_state(); + incomplete.own_instance = Some(MemberInstanceId::from_bytes([0x24; 32])); + incomplete.membership_reconciliation = None; + incomplete.migrated_from_pre_adr_020 = false; + + let mut missing_identity = incomplete.clone(); + missing_identity.phase = WorkspacePhase::Complete; + missing_identity.own_instance = None; + + let mut missing_peer_relationship = incomplete.clone(); + missing_peer_relationship.phase = WorkspacePhase::Complete; + missing_peer_relationship.peer_history_relationships.clear(); + + for state in [incomplete, missing_identity, missing_peer_relationship] { + let recovered = WorkspaceConvergenceState::from( + super::WorkspaceConvergenceStateV3Payload::from(state), + ); + assert!(!recovered.migrated_from_pre_adr_020); + } + } + #[tokio::test] async fn failed_v3_reopen_keeps_original_v2_ciphertext_and_no_active_pointer() { let (store, pool, _directory) = make_store(); diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index 2470698..d665813 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -1252,7 +1252,7 @@ node scripts/release/verify-release-bundle.mjs <产物目录> | 2026-08-03 | 首次建立架构总览,并固化 Agent 同步维护要求 | 建立当前架构基线 | | 2026-08-19 | 旧资料独立化评审修复 | 独立化流程在清理前保存唯一目标并在中断后复用;会话恢复与手动解锁共用同一后处理,产品可通过事件或状态查询恢复重新配对提示 | | 2026-08-19 | 旧资料升级识别收口 | Engine 启动与产品查询共用应用层升级判断;没有版本游标但已经完成初始化的旧资料会进入独立化,全新安装不会误触发。独立化是否需要的版本规则只保留在升级状态中。 | -| 2026-08-20 | rc.3 空间升级来源恢复 | 读取 rc.3 加密空间状态时,缺少已生效成员历史即恢复为旧资料迁移来源,使既有受控迁移流程可以补齐成员基线;不把设备记录直接提升为长期成员事实。 | +| 2026-08-20 | rc.3 空间升级来源恢复 | 读取 rc.3 加密空间状态时,缺少已生效成员历史即恢复为旧资料迁移来源;对已被早期修复转换的状态,仅在空间已完成、本机身份与既有设备关系均存在且成员历史缺失时恢复同一来源。既有受控迁移流程随后补齐成员基线,不把设备记录直接提升为长期成员事实。 | ## 相关文档 From 278826a4f3c0b574554b97a0e2a074047987f852 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 21 Aug 2026 00:48:34 +0800 Subject: [PATCH 5/6] fix: recover active rc3 workspace states --- .planning/.active_plan | 2 +- .../findings.md | 37 ++++++++ .../progress.md | 55 +++++++++++ .../task_plan.md | 57 +++++++++++ .../workspace_convergence_store.rs | 95 ++++++++++++++++++- docs/architecture/architecture-bible.md | 3 + 6 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 .planning/2026-08-21-rc3-workspace-recovery-fix/findings.md create mode 100644 .planning/2026-08-21-rc3-workspace-recovery-fix/progress.md create mode 100644 .planning/2026-08-21-rc3-workspace-recovery-fix/task_plan.md diff --git a/.planning/.active_plan b/.planning/.active_plan index f549a87..b175d27 100644 --- a/.planning/.active_plan +++ b/.planning/.active_plan @@ -1 +1 @@ -spec-025-user-initiated-join-supersession +2026-08-21-rc3-workspace-recovery-fix diff --git a/.planning/2026-08-21-rc3-workspace-recovery-fix/findings.md b/.planning/2026-08-21-rc3-workspace-recovery-fix/findings.md new file mode 100644 index 0000000..59f92fb --- /dev/null +++ b/.planning/2026-08-21-rc3-workspace-recovery-fix/findings.md @@ -0,0 +1,37 @@ +# Findings: rc.3 Workspace Recovery Repair + +## Requirements +- Make the recent workspace recovery actually repair the affected persisted profile. +- Preserve encrypted persistence and fail-closed behavior. +- Verify the work before reporting. +- Update `docs/architecture/architecture-bible.md` for every repository change. + +## Research Findings +- The desktop worktree pins Engine `f287d32715fbdac7655db5f8642e3301182ced68`. +- The latest launch still reports current peer scope unavailable and cascades into history delivery failures. +- Diagnostic commit `4e04a0d` observed 103 instances with Ready protection, three member rows, no current history, and `migrated_from_pre_adr_020=false`. +- `d3f7aef` converted rc.3 V2 state to V3 while preserving the false provenance bit. +- `8ce0c84` repairs direct V2 reads only, so it cannot repair a state already converted by `d3f7aef`. +- `f287d32` repairs V3 only when phase is `Complete`, own identity exists, peer relationships are nonempty, and current history is absent. +- Production state transitions initialize to `LocallyApplied`; the pre-ADR-020 converter uses `Converging`. Phase is therefore not durable migration provenance. +- `recover_legacy_migration_marker` only clears a true marker after current history exists. It does not reconstruct a missing marker. +- The repository test seam can write the exact rc.4 V3 layout directly into the active encrypted slot, then reopen it through current production loading. + +## Technical Decisions +| Decision | Rationale | +|----------|-----------| +| Reproduce the two-step migration in the repository test | It exercises the same persistence boundary that failed in production. | +| Remove only the invalid phase requirement if stable identity and peer evidence remain | This is the smallest change supported by the observed profile and preserves fail-closed checks. | +| Add explicit negative cases for missing identity, missing peer evidence, removed state, and recovery failure | These prevent broad inference from absence of current history alone. | + +## Issues Encountered +| Issue | Resolution | +|-------|------------| +| Sanitized diagnostics do not expose the persisted phase or relationship map | Use release source semantics and replay the exact upgrade sequence at the repository seam. | + +## Resources +- `crates/uc-infra/src/db/repositories/workspace_convergence_store.rs` +- `crates/uc-application/src/space/convergence/membership/bootstrap.rs` +- `docs/architecture/architecture-bible.md` +- `docs/adr/021-workspace-convergence-internal-boundaries.md` +- `docs/adr/023-legacy-profile-isolation-and-re-pairing.md` diff --git a/.planning/2026-08-21-rc3-workspace-recovery-fix/progress.md b/.planning/2026-08-21-rc3-workspace-recovery-fix/progress.md new file mode 100644 index 0000000..85a0f45 --- /dev/null +++ b/.planning/2026-08-21-rc3-workspace-recovery-fix/progress.md @@ -0,0 +1,55 @@ +# Progress Log: rc.3 Workspace Recovery Repair + +## Session: 2026-08-21 + +### Phase 1: Diagnose the production failure +- **Status:** complete +- Confirmed all four Engine commits and the matching desktop pin. +- Replayed the final launch from `uniclipboard-diagnostics-20260820-161225.zip`. +- Verified the latest launch still reports the exact peer-scope failure. +- Ran both existing V3 recovery tests with one test executed in each command; both passed, proving they cover only the narrow modeled states. + +### Phase 2: Write the regression first +- **Status:** complete +- Added repository-level replay tests for rc.4 V3 states originating from rc.3 with `LocallyApplied` and `Converging` phases. +- Reworked the existing negative test to preserve fail-closed cases without treating a mutable phase as missing evidence. +- Listed both tests before running them, then executed both with a nonzero count. +- Both failed at the intended assertion because `migrated_from_pre_adr_020` remained false. + +### Phase 3: Apply the smallest durable fix +- **Status:** complete +- Replaced the `Complete` requirement with explicit not-removed, non-recovery, and no-failure guards. +- Positive replay tests now pass for both `LocallyApplied` and `Converging` states. +- Added an explicit stored-failure negative case, removed its production guard, and watched the negative test fail at the intended assertion before restoring the guard. + +### Phase 4: Verify behavior and repository health +- **Status:** complete +- Ran all 19 workspace convergence store tests; all passed with a nonzero count. +- Passed workspace all-target checks, locked metadata, formatting, architecture validation, and diff validation. +- Updated the architecture bible behavior description and maintenance record. + +## Test Results +| Test | Expected | Actual | Status | +|------|----------|--------|--------| +| Latest diagnostic launch replay | Failure detected | 1 peer-scope failure after final launch | red | +| Existing positive V3 recovery test | 1 test passes | 1 passed | pass | +| Existing negative V3 recovery test | 1 test passes | 1 passed | pass | +| Real rc.4 V3 replay for nonterminal rc.3 states | 2 tests fail before implementation | 0 passed, 2 failed at provenance assertion | red | +| Real rc.4 V3 replay after implementation | 2 tests pass | 2 passed | pass | +| Stored-failure fail-closed guard without implementation | 1 test fails | Failed at legacy-provenance assertion | red | +| Complete workspace convergence store suite | 19 tests pass | 19 passed | pass | +| Repository delivery checks | All required checks pass | All passed | pass | + +## Error Log +| Timestamp | Error | Attempt | Resolution | +|-----------|-------|---------|------------| +| 2026-08-21 | Streaming log assertion incorrectly passed | 1 | Counted matching events after the final launch before deciding pass/fail. | + +## 5-Question Reboot Check +| Question | Answer | +|----------|--------| +| Where am I? | Complete and ready to commit | +| Where am I going? | Local verified commit | +| What's the goal? | Recover already-converted rc.3 workspaces safely | +| What have I learned? | The phase requirement is not stable migration evidence | +| What have I done? | Reproduced the failure, repaired it, and passed focused plus repository checks | diff --git a/.planning/2026-08-21-rc3-workspace-recovery-fix/task_plan.md b/.planning/2026-08-21-rc3-workspace-recovery-fix/task_plan.md new file mode 100644 index 0000000..ebce61b --- /dev/null +++ b/.planning/2026-08-21-rc3-workspace-recovery-fix/task_plan.md @@ -0,0 +1,57 @@ +# Task Plan: rc.3 Workspace Recovery Repair + +## Goal +Recover affected rc.3 workspaces that were already converted by the first rc.4 repair, without weakening fail-closed handling for unrelated or incomplete state. + +## Next Step +Commit the verified recovery repair. + +## Current Phase +Complete + +## Phases + +### Phase 1: Diagnose the production failure +- [x] Confirm the desktop build pins the latest Engine repair. +- [x] Replay the latest diagnostic log and reproduce the exact failure. +- [x] Identify the gap between the migration tests and the real upgrade sequence. +- **Status:** complete + +### Phase 2: Write the regression first +- [x] Model an rc.3 legacy-origin state with normal nonterminal phases. +- [x] Write it using the early rc.4 V3 layout with lost provenance. +- [x] Prove the current code fails to restore provenance after reopen. +- **Status:** complete + +### Phase 3: Apply the smallest durable fix +- [x] Restore provenance from stable legacy evidence without requiring `Complete`. +- [x] Preserve rejection for new, removed, failed, or evidence-free states. +- [x] Update the architecture bible and maintenance record. +- **Status:** complete + +### Phase 4: Verify behavior and repository health +- [x] Pass focused repository tests with a nonzero count. +- [x] Pass affected crate and workspace checks. +- [x] Run required architecture, formatting, metadata, and diff checks. +- [x] Confirm no debug instrumentation or unrelated changes remain. +- **Status:** complete + +## Key Questions +1. Which persisted facts survive both rc.3 and the early rc.4 conversion and uniquely identify this legacy-origin state? +2. Can recovery avoid relying on the mutable workspace phase? +3. Which negative cases prevent a fresh or unrelated state from being promoted? + +## Decisions Made +| Decision | Rationale | +|----------|-----------| +| Test the full persisted upgrade sequence | Direct conversion tests missed the already-migrated user state. | +| Treat workspace phase as mutable, not migration provenance | Production transitions normally use nonterminal phases. | + +## Errors Encountered +| Error | Attempt | Resolution | +|-------|---------|------------| +| Initial streaming log assertion returned a false pass | 1 | Replaced it with a deterministic count over the final launch. | + +## Notes +- The original diagnostic replay remains the user-symptom boundary, but a repository sequence test is the agent-runnable fix loop. +- Do not alter public interfaces or persistence encryption. diff --git a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs index d46a203..e3777c3 100644 --- a/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs +++ b/crates/uc-infra/src/db/repositories/workspace_convergence_store.rs @@ -405,7 +405,9 @@ impl From for WorkspaceConvergenceStateV3Payload { impl From for WorkspaceConvergenceState { fn from(state: WorkspaceConvergenceStateV3Payload) -> Self { let migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020 - || (state.phase == WorkspacePhase::Complete + || (!state.removed + && state.phase != WorkspacePhase::RecoveryRequired + && state.failure_category.is_none() && state.own_instance.is_some() && !state.peer_history_relationships.is_empty() && state @@ -1131,6 +1133,44 @@ mod tests { .unwrap(); } + fn insert_rc4_v3_state(pool: &DbPool, state: &WorkspaceConvergenceState) { + let master_key = MasterKey::from_bytes(&[0x57; 32]).unwrap(); + let space_id = SpaceId::from_str(SPACE); + let lookup_token = super::space_lookup_token(&master_key, &space_id).unwrap(); + let slot_id = "rc4-active-slot"; + let stored = Rc4WorkspaceConvergenceStateV3 { + storage_version: super::WORKSPACE_STATE_V3_STORAGE_VERSION, + state: state.clone(), + }; + let encrypted = super::seal_prefixed_payload( + &master_key, + super::WORKSPACE_STATE_V3_PREFIX, + &stored, + &super::workspace_v3_slot_aad(SPACE, slot_id), + ) + .unwrap(); + let mut connection = pool.get().unwrap(); + diesel::sql_query( + "INSERT INTO workspace_convergence_v3_slots \ + (space_lookup_token, slot_id, encrypted_payload, updated_at_ms) VALUES (?, ?, ?, ?)", + ) + .bind::(&lookup_token) + .bind::(slot_id) + .bind::(encrypted) + .bind::(state.updated_at_ms) + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "INSERT INTO workspace_convergence_v3_active \ + (space_lookup_token, slot_id, generation) VALUES (?, ?, ?)", + ) + .bind::(lookup_token) + .bind::(slot_id) + .bind::(1_i64) + .execute(&mut connection) + .unwrap(); + } + fn rc3_v2_plaintext(state: &WorkspaceConvergenceState) -> Vec { let rc3_state = Rc3WorkspaceConvergenceState::from(state.clone()); let mut plaintext = super::WORKSPACE_STATE_V2_PREFIX.to_vec(); @@ -1653,6 +1693,36 @@ mod tests { assert_eq!(WorkspaceConvergenceState::from(stored.state), expected); } + #[tokio::test] + async fn rc4_v3_state_migrated_from_rc3_while_locally_applied_recovers_legacy_origin() { + let (_store, pool, _directory) = make_store(); + let mut affected = persisted_state(); + affected.own_instance = Some(MemberInstanceId::from_bytes([0x24; 32])); + affected.phase = WorkspacePhase::LocallyApplied; + affected.membership_reconciliation = None; + affected.migrated_from_pre_adr_020 = false; + insert_rc4_v3_state(&pool, &affected); + + let recovered = reopen_store(pool).load_state().await.unwrap().unwrap(); + + assert!(recovered.migrated_from_pre_adr_020); + } + + #[tokio::test] + async fn rc4_v3_state_migrated_from_rc3_while_converging_recovers_legacy_origin() { + let (_store, pool, _directory) = make_store(); + let mut affected = persisted_state(); + affected.own_instance = Some(MemberInstanceId::from_bytes([0x24; 32])); + affected.phase = WorkspacePhase::Converging; + affected.membership_reconciliation = None; + affected.migrated_from_pre_adr_020 = false; + insert_rc4_v3_state(&pool, &affected); + + let recovered = reopen_store(pool).load_state().await.unwrap().unwrap(); + + assert!(recovered.migrated_from_pre_adr_020); + } + #[test] fn v3_state_previously_migrated_without_provenance_recovers_legacy_origin() { let mut affected = persisted_state(); @@ -1668,21 +1738,36 @@ mod tests { } #[test] - fn v3_state_without_complete_legacy_evidence_remains_fail_closed() { + fn v3_state_without_durable_legacy_evidence_remains_fail_closed() { let mut incomplete = persisted_state(); incomplete.own_instance = Some(MemberInstanceId::from_bytes([0x24; 32])); incomplete.membership_reconciliation = None; incomplete.migrated_from_pre_adr_020 = false; let mut missing_identity = incomplete.clone(); - missing_identity.phase = WorkspacePhase::Complete; missing_identity.own_instance = None; let mut missing_peer_relationship = incomplete.clone(); - missing_peer_relationship.phase = WorkspacePhase::Complete; missing_peer_relationship.peer_history_relationships.clear(); - for state in [incomplete, missing_identity, missing_peer_relationship] { + let mut removed = incomplete.clone(); + removed.removed = true; + + let mut recovery_required = incomplete; + recovery_required.phase = WorkspacePhase::RecoveryRequired; + + let mut integrity_failure = recovery_required.clone(); + integrity_failure.phase = WorkspacePhase::LocallyApplied; + integrity_failure.failure_category = + Some(uc_core::membership::WorkspaceFailureCategory::Storage); + + for state in [ + missing_identity, + missing_peer_relationship, + removed, + recovery_required, + integrity_failure, + ] { let recovered = WorkspaceConvergenceState::from( super::WorkspaceConvergenceStateV3Payload::from(state), ); diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index d665813..3c54503 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -946,6 +946,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> ## 文档维护记录 +- 2026-08-21:补全已被早期修复转换为新格式的 `1.1.0-rc.3` 空间恢复。恢复判断不再依赖会随正常流程变化的完成阶段,而以本机身份、既有设备关系、未移除、无恢复失败且缺少成员历史这些持久事实为准;处于正常应用或收敛中的受影响空间也能恢复迁移来源,已移除、需要人工恢复、已有失败或证据不足的状态仍保持拒绝。不改变负责人、对外接口或密文持久化格式。 + - 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 @@ -1252,6 +1254,7 @@ node scripts/release/verify-release-bundle.mjs <产物目录> | 2026-08-03 | 首次建立架构总览,并固化 Agent 同步维护要求 | 建立当前架构基线 | | 2026-08-19 | 旧资料独立化评审修复 | 独立化流程在清理前保存唯一目标并在中断后复用;会话恢复与手动解锁共用同一后处理,产品可通过事件或状态查询恢复重新配对提示 | | 2026-08-19 | 旧资料升级识别收口 | Engine 启动与产品查询共用应用层升级判断;没有版本游标但已经完成初始化的旧资料会进入独立化,全新安装不会误触发。独立化是否需要的版本规则只保留在升级状态中。 | +| 2026-08-21 | rc.3 空间升级来源恢复补全 | 对已被早期修复转换的状态,不再要求空间处于完成阶段;本机身份与既有设备关系存在、成员历史缺失,并且空间未移除、未进入人工恢复且没有保存失败时恢复旧资料迁移来源。正常应用和收敛阶段都可继续既有受控迁移,证据不足或失败状态仍保持拒绝。 | | 2026-08-20 | rc.3 空间升级来源恢复 | 读取 rc.3 加密空间状态时,缺少已生效成员历史即恢复为旧资料迁移来源;对已被早期修复转换的状态,仅在空间已完成、本机身份与既有设备关系均存在且成员历史缺失时恢复同一来源。既有受控迁移流程随后补齐成员基线,不把设备记录直接提升为长期成员事实。 | ## 相关文档 From e49e93374f4562e6252d4c1c46206bd11fed07ce Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Fri, 21 Aug 2026 01:51:24 +0800 Subject: [PATCH 6/6] fix: recover missing workspace convergence state --- .planning/.active_plan | 2 +- .../findings.md | 18 ++++++++ .../progress.md | 21 +++++++++ .../task_plan.md | 46 +++++++++++++++++++ .../space/convergence/membership/bootstrap.rs | 7 ++- .../src/space/convergence/membership/tests.rs | 46 +++++++++++++++++++ .../src/space/convergence/mod.rs | 22 +++++++-- .../convergence/projection/current_scope.rs | 34 +++++++++++--- crates/uc-engine/src/assembly/sync_engine.rs | 9 +++- docs/architecture/architecture-bible.md | 16 +++++-- 10 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 .planning/2026-08-21-missing-workspace-state-recovery/findings.md create mode 100644 .planning/2026-08-21-missing-workspace-state-recovery/progress.md create mode 100644 .planning/2026-08-21-missing-workspace-state-recovery/task_plan.md diff --git a/.planning/.active_plan b/.planning/.active_plan index b175d27..3e93b06 100644 --- a/.planning/.active_plan +++ b/.planning/.active_plan @@ -1 +1 @@ -2026-08-21-rc3-workspace-recovery-fix +2026-08-21-missing-workspace-state-recovery diff --git a/.planning/2026-08-21-missing-workspace-state-recovery/findings.md b/.planning/2026-08-21-missing-workspace-state-recovery/findings.md new file mode 100644 index 0000000..54d3807 --- /dev/null +++ b/.planning/2026-08-21-missing-workspace-state-recovery/findings.md @@ -0,0 +1,18 @@ +# Findings: Missing Workspace State Recovery + +## Production Evidence +- The final `17:03:36Z` launch resumed the key session, then reported three peer-scope failures and forty delivery-view failures. +- The Windows dev database was created on 2026-07-18 and still contains three member, three trusted-peer, and three peer-address relationships. +- `workspace_convergence_state`, `workspace_convergence_v3_active`, `workspace_convergence_v3_slots`, and `workspace_convergence_v3_migrations` all contain zero rows. +- The database therefore matches an initialized legacy profile with no convergence state, not a malformed V2 or V3 convergence row. + +## Root Cause +- `build_sync_engine_assembly` always supplies `WorkspaceConvergenceStateOrigin::CurrentInstallation`. +- When the repository returns no state, `WorkspaceConvergence::load_state` creates a fresh state and only sets legacy provenance for `UpgradeWithoutConvergenceState`. +- All previous repairs operate on existing V2 or V3 payloads and cannot affect this profile. + +## Constraints +- The decision belongs to `WorkspaceConvergence`; desktop callers must not orchestrate it. +- Missing state alone is insufficient because a fresh install is also empty. +- Recovery must use durable initialized-profile evidence, run after the encrypted session is ready, and fail closed on unreadable evidence. +- No user content, device names, filenames, or paths may be logged. diff --git a/.planning/2026-08-21-missing-workspace-state-recovery/progress.md b/.planning/2026-08-21-missing-workspace-state-recovery/progress.md new file mode 100644 index 0000000..cab1a87 --- /dev/null +++ b/.planning/2026-08-21-missing-workspace-state-recovery/progress.md @@ -0,0 +1,21 @@ +# Progress: Missing Workspace State Recovery + +## 2026-08-21 +- Confirmed the original failure with a deterministic log replay: one session resume, three peer-scope failures, and forty delivery-view failures. +- Inspected the Windows dev database read-only and found legacy relationship rows with zero convergence-state rows. +- Located the incorrect current-installation classification in Engine assembly and the missing-state fallback in `WorkspaceConvergence::load_state`. +- Added production-shaped tests for existing-install origin, durable missing-state recovery, and the fresh-install negative case. +- The first short exact Cargo filter ran zero tests; it is not counted as evidence. +- Listed the full names and ran nonzero tests: the existing-install classification failed with `CurrentInstallation`, durable recovery failed because no state was saved, and the fresh-install negative case passed. +- Applied the minimal production change: derive origin from whether startup detected a prior installation and persist a newly inferred legacy state during recovery. +- Reran the focused missing-state tests with nonzero counts: both durable recovery cases passed, and the existing-install classification passed. +- Updated the architecture behavior and maintenance record for initialized profiles with no convergence row. +- Next: run the broader convergence suite and all mandatory repository checks. + +## Test Results +| Check | Result | +|---|---| +| Final launch log replay | RED: bug reproduced after session resume | +| Live database state shape | Confirmed: legacy relationships present, convergence rows absent | +| Missing-state recovery tests | GREEN: 2 passed | +| Existing-install origin test | GREEN: 1 passed | diff --git a/.planning/2026-08-21-missing-workspace-state-recovery/task_plan.md b/.planning/2026-08-21-missing-workspace-state-recovery/task_plan.md new file mode 100644 index 0000000..598c309 --- /dev/null +++ b/.planning/2026-08-21-missing-workspace-state-recovery/task_plan.md @@ -0,0 +1,46 @@ +# Task Plan: Missing Workspace State Recovery + +## Goal +Recover an upgraded initialized profile that retains legacy member relationships but has no persisted workspace convergence state. + +## Current Phase +Phase 4: Verify and document + +## Phases + +### Phase 1: Confirm the production data shape +- [x] Replay the final launch failure after session resume. +- [x] Inspect the Windows dev database read-only. +- [x] Confirm legacy relationships exist while all convergence state tables are empty. +- **Status:** complete + +### Phase 2: Write the regression first +- [x] Reproduce missing state plus initialized legacy relationships at the application owner seam. +- [x] Prove current code classifies it as a current installation and does not save recovery state. +- [x] Preserve a fresh-install negative case. +- **Status:** complete + +### Phase 3: Implement the minimal owner-side repair +- [x] Derive missing-state origin from durable initialized-profile evidence. +- [x] Persist the recovered encrypted state after session readiness. +- [x] Keep callers and public interfaces unchanged. +- **Status:** complete + +### Phase 4: Verify and document +- [ ] Pass the focused regression and convergence suites with nonzero counts. +- [ ] Run all required repository checks. +- [x] Update the architecture bible behavior and maintenance record. +- [ ] Recheck the original diagnostic failure boundary. +- **Status:** in_progress + +## Completion Criteria +- The production-shaped missing-state regression fails before and passes after the fix. +- Fresh installations remain unclassified as legacy upgrades. +- The recovered state is encrypted and survives reopen. +- All mandatory repository checks pass. + +## Errors Encountered +| Error | Resolution | +|---|---| +| Earlier fixes assumed a convergence row existed | Use the live database row counts as the regression fixture shape. | +| First exact Cargo filter executed zero tests | List full test names, then rerun each exact fully-qualified name and verify nonzero counts. | diff --git a/crates/uc-application/src/space/convergence/membership/bootstrap.rs b/crates/uc-application/src/space/convergence/membership/bootstrap.rs index 8303d65..9d65492 100644 --- a/crates/uc-application/src/space/convergence/membership/bootstrap.rs +++ b/crates/uc-application/src/space/convergence/membership/bootstrap.rs @@ -41,8 +41,11 @@ impl WorkspaceConvergence { &self, ) -> Result<(), WorkspaceConvergenceError> { let _guard = self.state_lock.lock().await; - let mut state = self.load_state().await?; - if Self::clear_legacy_migration_marker_if_current_history_exists(&mut state) { + let (mut state, was_persisted) = self.load_state_with_presence().await?; + let initialized_missing_legacy_state = !was_persisted && state.migrated_from_pre_adr_020; + if initialized_missing_legacy_state + || Self::clear_legacy_migration_marker_if_current_history_exists(&mut state) + { self.persist(&state).await?; } Ok(()) diff --git a/crates/uc-application/src/space/convergence/membership/tests.rs b/crates/uc-application/src/space/convergence/membership/tests.rs index de8081f..7cadbbe 100644 --- a/crates/uc-application/src/space/convergence/membership/tests.rs +++ b/crates/uc-application/src/space/convergence/membership/tests.rs @@ -876,6 +876,52 @@ async fn runtime_clears_a_stale_legacy_marker_after_current_history_exists() { assert_eq!(repository.load_state().await.unwrap().unwrap(), repaired); } +#[test] +fn existing_installation_without_convergence_state_keeps_upgrade_origin() { + assert_eq!( + WorkspaceConvergenceStateOrigin::from_version_transition(Some("1.1.0-rc.4"), "1.1.0-rc.4"), + WorkspaceConvergenceStateOrigin::UpgradeWithoutConvergenceState + ); +} + +#[tokio::test] +async fn recovery_persists_missing_state_for_an_existing_installation() { + let repository = MemoryWorkspaceRepository::default(); + let mut deps = test_deps(Arc::new(repository.clone()), "device-a", Vec::new()); + deps.initial_state_origin = WorkspaceConvergenceStateOrigin::UpgradeWithoutConvergenceState; + deps.member_repo = Arc::new(FixedMemberRepo(vec![ + legacy_member("device-a"), + legacy_member("device-b"), + ])); + deps.space_protection = Arc::new(ProtectsQueriedMembers::default()); + let owner = WorkspaceConvergence::new(deps); + + owner.recover_legacy_migration_marker().await.unwrap(); + + let recovered = repository.load_state().await.unwrap().unwrap(); + assert!(recovered.migrated_from_pre_adr_020); + let snapshot = owner.snapshot().await.unwrap(); + assert_eq!( + snapshot.source, + uc_core::membership::CurrentWorkspacePeerScopeSource::Legacy + ); + assert_eq!(snapshot.peer_device_ids, vec![DeviceId::new("device-b")]); +} + +#[tokio::test] +async fn recovery_does_not_persist_missing_state_for_a_fresh_installation() { + let repository = MemoryWorkspaceRepository::default(); + let owner = WorkspaceConvergence::new(test_deps( + Arc::new(repository.clone()), + "device-a", + Vec::new(), + )); + + owner.recover_legacy_migration_marker().await.unwrap(); + + assert!(repository.load_state().await.unwrap().is_none()); +} + #[tokio::test] async fn isolated_legacy_profile_starts_with_fresh_single_member_history() { let old_a = instance(0x0a); diff --git a/crates/uc-application/src/space/convergence/mod.rs b/crates/uc-application/src/space/convergence/mod.rs index cf7d8c3..e3e60b6 100644 --- a/crates/uc-application/src/space/convergence/mod.rs +++ b/crates/uc-application/src/space/convergence/mod.rs @@ -147,8 +147,12 @@ pub enum WorkspaceConvergenceStateOrigin { } impl WorkspaceConvergenceStateOrigin { - pub fn from_version_transition(_previous: Option<&str>, _current: &str) -> Self { - Self::CurrentInstallation + pub fn from_version_transition(previous: Option<&str>, _current: &str) -> Self { + if previous.is_some() { + Self::UpgradeWithoutConvergenceState + } else { + Self::CurrentInstallation + } } } @@ -450,7 +454,9 @@ impl WorkspaceConvergence { self.wake.notify_waiters(); } - async fn load_state(&self) -> Result { + async fn load_state_with_presence( + &self, + ) -> Result<(WorkspaceConvergenceState, bool), WorkspaceConvergenceError> { let lineage = self .deps .membership_identity @@ -458,7 +464,9 @@ impl WorkspaceConvergence { .await .map(|identity| identity.space_id.as_ref().to_owned()) .unwrap_or_default(); - let mut state = match self.deps.repository.load_state().await? { + let persisted = self.deps.repository.load_state().await?; + let was_persisted = persisted.is_some(); + let mut state = match persisted { Some(state) => state, None => { let mut state = @@ -473,7 +481,11 @@ impl WorkspaceConvergence { if state.space_lineage.is_empty() { state.space_lineage = lineage; } - Ok(state) + Ok((state, was_persisted)) + } + + async fn load_state(&self) -> Result { + Ok(self.load_state_with_presence().await?.0) } async fn persist( diff --git a/crates/uc-application/src/space/convergence/projection/current_scope.rs b/crates/uc-application/src/space/convergence/projection/current_scope.rs index 0107b78..19a1aea 100644 --- a/crates/uc-application/src/space/convergence/projection/current_scope.rs +++ b/crates/uc-application/src/space/convergence/projection/current_scope.rs @@ -173,12 +173,13 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .as_ref() .filter(|history| history.applied_head().is_some()); let Some(history) = history else { - let members = self - .deps - .member_repo - .list() - .await - .map_err(|_| CurrentWorkspacePeerScopeError::Unavailable)?; + let members = self.deps.member_repo.list().await.map_err(|_| { + tracing::warn!( + error_kind = "current_peer_scope_legacy_member_roster_read", + "current peer scope unavailable" + ); + CurrentWorkspacePeerScopeError::Unavailable + })?; let member_ids = members .iter() .map(|member| member.device_id) @@ -194,13 +195,32 @@ impl uc_core::membership::CurrentWorkspacePeerScopePort for WorkspaceConvergence .await .map_err(|error| match error { uc_core::membership::SpaceProtectionError::Corrupted => { + tracing::warn!( + error_kind = "current_peer_scope_legacy_protection_read", + error_category = "corrupt", + "current peer scope unavailable" + ); CurrentWorkspacePeerScopeError::Corrupt } - _ => CurrentWorkspacePeerScopeError::Unavailable, + _ => { + tracing::warn!( + error_kind = "current_peer_scope_legacy_protection_read", + error_category = "unavailable", + "current peer scope unavailable" + ); + CurrentWorkspacePeerScopeError::Unavailable + } })?; if protection.mode != uc_core::membership::SpaceProtectionMode::Legacy && !state.migrated_from_pre_adr_020 { + tracing::warn!( + error_kind = "current_peer_scope_current_history_absent", + protection_mode = ?protection.mode, + migrated_from_pre_adr_020 = state.migrated_from_pre_adr_020, + member_record_count = member_ids.len(), + "current peer scope unavailable" + ); return Err(CurrentWorkspacePeerScopeError::Unavailable); } let local_is_member = protection.mode diff --git a/crates/uc-engine/src/assembly/sync_engine.rs b/crates/uc-engine/src/assembly/sync_engine.rs index 48a3d6d..a8d350f 100644 --- a/crates/uc-engine/src/assembly/sync_engine.rs +++ b/crates/uc-engine/src/assembly/sync_engine.rs @@ -606,8 +606,15 @@ pub async fn build_sync_engine_assembly( ) { upgrade.acknowledge(current_app_version).await?; } + let previous_installation = !matches!( + upgrade_status, + uc_application::facade::UpgradeStatus::FreshInstall + ); let initial_state_origin = - uc_application::facade::WorkspaceConvergenceStateOrigin::CurrentInstallation; + uc_application::facade::WorkspaceConvergenceStateOrigin::from_version_transition( + previous_installation.then_some(current_app_version), + current_app_version, + ); let legacy_profile_isolation_required = upgrade_status.requires_legacy_profile_isolation(); // IdentityFingerprintFactory is stateless — the one in SecurityPorts is // the same `Sha256IdentityFingerprintFactory` ZST, but we construct a diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index 3c54503..73a0ee2 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -495,12 +495,12 @@ Engine、iOS、Android 和 HarmonyOS 共用同一版本契约,三个移动平 初始化为空,再通过保留源密文、旁写目标、重新读取核验和最后切换的流程迁移到 V3。任何解析、身份 核对、旁写或复读失败都保留 V2 原密文且不启用半成品,问题版本已发生过的失败读取不影响后续修复版 重试恢复; -v0.19.1 尚未保存收敛状态行,Engine 在首次打开当前格式时读取该档案已有的应用版本记录。只有记录中的 -原版本低于当前版本且收敛状态行确实不存在时,才标记为等待旧空间共同升级,不能补造签名成员历史。 +旧版资料可能尚未保存收敛状态行。Engine 只把确认的首次安装视为新资料;已经初始化的安装只要状态行 +确实不存在,就建立等待旧空间共同升级的来源,并在会话就绪后的恢复流程中加密保存。这样即使应用版本 +记录已被先前版本推进到当前版本,保留的旧成员关系也仍能进入既有受控迁移,不能补造签名成员历史。 已有收敛状态始终优先;共同保护关系和当前签名成员历史都覆盖保留成员后才清除迁移标记,成员历史不得 -先于共同保护关系结束升级。全新安装、同版本启动、回退、缺失或 -不可解析的版本字段都不能开启旧成员范围;版本记录文件读取失败或损坏时停止本次启动。无法按任一受支持 -布局读取的数据保持关闭式失败,并通过独立稳定错误与普通收敛失败区分。 +先于共同保护关系结束升级。全新安装不能开启旧成员范围;版本记录文件读取失败或损坏时停止本次启动。 +无法按任一受支持布局读取的数据保持关闭式失败,并通过独立稳定错误与普通收敛失败区分。 新建空间尚未建立签名成员历史时,设备信任查询复用同一负责人给出的当前成员范围结论。明确处于 旧保护状态时,已恢复的本机空间会话证明本机仍在空间中;旧成员表只提供其他设备,不能要求它重复 保存本机身份。缺少旧保护状态或当前签名历史时继续返回不可用,不根据设备数量或到达顺序猜测成员 @@ -946,6 +946,10 @@ node scripts/release/verify-release-bundle.mjs <产物目录> ## 文档维护记录 +- 2026-08-21:补充当前成员范围不可用时的脱敏原因分类,区分旧成员记录读取、空间保护关系读取以及缺少当前成员历史;只记录固定类别、保护模式、布尔值和数量,不记录设备名称、文件名、路径或用户内容。不改变成员判断、持久化或对外接口。 + +- 2026-08-21:修复已初始化旧资料完全缺少空间收敛记录时无法恢复的问题。除确认的首次安装外,缺少记录的既有安装会建立并加密保存旧资料迁移来源;已有记录始终优先,全新安装保持原行为,恢复过程不补造签名成员历史。不改变负责人、对外接口或持久化格式。 + - 2026-08-21:补全已被早期修复转换为新格式的 `1.1.0-rc.3` 空间恢复。恢复判断不再依赖会随正常流程变化的完成阶段,而以本机身份、既有设备关系、未移除、无恢复失败且缺少成员历史这些持久事实为准;处于正常应用或收敛中的受影响空间也能恢复迁移来源,已移除、需要人工恢复、已有失败或证据不足的状态仍保持拒绝。不改变负责人、对外接口或密文持久化格式。 - 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 @@ -1254,6 +1258,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> | 2026-08-03 | 首次建立架构总览,并固化 Agent 同步维护要求 | 建立当前架构基线 | | 2026-08-19 | 旧资料独立化评审修复 | 独立化流程在清理前保存唯一目标并在中断后复用;会话恢复与手动解锁共用同一后处理,产品可通过事件或状态查询恢复重新配对提示 | | 2026-08-19 | 旧资料升级识别收口 | Engine 启动与产品查询共用应用层升级判断;没有版本游标但已经完成初始化的旧资料会进入独立化,全新安装不会误触发。独立化是否需要的版本规则只保留在升级状态中。 | +| 2026-08-21 | 当前成员范围失败分类 | 失败日志只记录旧成员记录读取、空间保护关系读取或当前历史缺失等固定类别,以及必要的模式、布尔值和数量;不改变成员判断。 | +| 2026-08-21 | 缺失空间收敛记录的旧资料恢复 | 只有确认的首次安装按新资料处理;既有安装缺少收敛记录时建立并加密保存旧资料迁移来源,已有记录优先,不补造签名成员历史。 | | 2026-08-21 | rc.3 空间升级来源恢复补全 | 对已被早期修复转换的状态,不再要求空间处于完成阶段;本机身份与既有设备关系存在、成员历史缺失,并且空间未移除、未进入人工恢复且没有保存失败时恢复旧资料迁移来源。正常应用和收敛阶段都可继续既有受控迁移,证据不足或失败状态仍保持拒绝。 | | 2026-08-20 | rc.3 空间升级来源恢复 | 读取 rc.3 加密空间状态时,缺少已生效成员历史即恢复为旧资料迁移来源;对已被早期修复转换的状态,仅在空间已完成、本机身份与既有设备关系均存在且成员历史缺失时恢复同一来源。既有受控迁移流程随后补齐成员基线,不把设备记录直接提升为长期成员事实。 |