From b9ce838b85c7af765af0a2783ae07235863fd18d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:26:47 +0900 Subject: [PATCH 01/20] test(zotero): specify rollback execution boundary --- .../tests/classification_rollback.rs | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/classification_rollback.rs diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs new file mode 100644 index 00000000..0ced8270 --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -0,0 +1,226 @@ +use conceptweave_zotero::{ + ClassificationItemState, ClassificationRollbackOutcome, ClassificationWriteRequest, + Disposition, ItemData, ItemTag, ReviewedClassificationChange, + ReviewedClassificationWriteSet, WriteMode, ZoteroItem, build_classification_write_plan, + classify_snapshot, execute_classification_rollback, execute_classification_write_plan, +}; + +fn tag(name: &str) -> ItemTag { + ItemTag { + tag: name.into(), + tag_type: None, + } +} + +fn plan() -> conceptweave_zotero::ClassificationWritePlan { + let report = classify_snapshot( + "10.0.0".into(), + Some("server-1".into()), + 42, + vec![ + ZoteroItem { + key: "A".into(), + version: 7, + data: ItemData { + item_type: "book".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }, + ZoteroItem { + key: "B".into(), + version: 9, + data: ItemData { + item_type: "book".into(), + title: "ontology evaluation".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec!["source".into()], + tags: vec![tag("Imported")], + }, + }, + ], + ); + let reviewed = ReviewedClassificationWriteSet { + review_id: "review-1".into(), + authority_receipt: "authority-1".into(), + server_id: report.server_id.clone(), + zotero_version: report.zotero_version.clone(), + library_version: report.library_version, + rule_revision: report.rule_revision.into(), + snapshot_digest: report.snapshot_digest.clone(), + snapshot_items: report.snapshot_items.clone(), + changes: vec![ + ReviewedClassificationChange { + item_key: "A".into(), + item_version: 7, + reviewed_disposition: Disposition::Generation, + before_collection_keys: vec![], + after_collection_keys: vec!["generation".into()], + before_tags: vec![], + after_tags: vec![tag("Generation")], + }, + ReviewedClassificationChange { + item_key: "B".into(), + item_version: 9, + reviewed_disposition: Disposition::EvaluationGovernance, + before_collection_keys: vec!["source".into()], + after_collection_keys: vec!["evaluation".into()], + before_tags: vec![tag("Imported")], + after_tags: vec![tag("Evaluation")], + }, + ], + }; + build_classification_write_plan(&report, &reviewed, WriteMode::Execute, |_| true).unwrap() +} + +fn applied_receipt() -> conceptweave_zotero::ClassificationWriteReceipt { + let plan = plan(); + execute_classification_write_plan( + &plan, + |key| { + let operation = plan.operations().iter().find(|item| item.item_key == key).unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: "server-1".into(), + library_version: 42, + item_key: key.into(), + item_version: operation.item_version, + collection_keys: operation.before_collection_keys.clone(), + tags: operation.before_tags.clone(), + }) + }, + |request| Ok::<_, ()>(written(request)), + ) +} + +fn written(request: &ClassificationWriteRequest) -> ClassificationItemState { + ClassificationItemState { + server_id: request.server_id.clone(), + library_version: request.library_version + 1, + item_key: request.item_key.clone(), + item_version: request.item_version + 1, + collection_keys: request.collection_keys.clone(), + tags: request.tags.clone(), + } +} + +#[test] +fn rollback_preflights_every_item_then_restores_in_receipt_order() { + let receipt = applied_receipt(); + assert_eq!(receipt.rollback_operations[0].item_key, "B"); + assert_eq!(receipt.rollback_operations[0].server_id, "server-1"); + assert_eq!(receipt.rollback_operations[0].expected_collection_keys, ["evaluation"]); + assert_eq!(receipt.rollback_operations[0].expected_tags, [tag("Evaluation")]); + + let mut reads = Vec::new(); + let mut writes = Vec::new(); + let result = execute_classification_rollback( + &receipt.rollback_operations, + |key| { + reads.push(key.to_owned()); + let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: 44, + item_key: key.into(), + item_version: operation.item_version, + collection_keys: operation.expected_collection_keys.clone(), + tags: operation.expected_tags.clone(), + }) + }, + |request| { + writes.push((request.item_key.clone(), request.library_version)); + Ok::<_, ()>(written(request)) + }, + ); + + assert_eq!(reads, ["B", "A"]); + assert_eq!(writes, [("B".into(), 44), ("A".into(), 45)]); + assert_eq!(result.outcome, ClassificationRollbackOutcome::Restored); + assert_eq!(result.restored_item_keys, ["B", "A"]); + assert!(result.failed_item_key.is_none()); + assert!(result.indeterminate_item_key.is_none()); + assert!(result.not_attempted_item_keys.is_empty()); + assert!(result.remaining_operations.is_empty()); +} + +#[test] +fn rollback_is_one_shot_and_second_execution_fails_before_write() { + let receipt = applied_receipt(); + let mut restored = false; + let mut writes = 0; + let mut run = || { + execute_classification_rollback( + &receipt.rollback_operations, + |key| { + let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: if restored { 46 } else { 44 }, + item_key: key.into(), + item_version: operation.item_version + u64::from(restored), + collection_keys: if restored { operation.collection_keys.clone() } else { operation.expected_collection_keys.clone() }, + tags: if restored { operation.tags.clone() } else { operation.expected_tags.clone() }, + }) + }, + |request| { + writes += 1; + Ok::<_, ()>(written(request)) + }, + ) + }; + assert_eq!(run().outcome, ClassificationRollbackOutcome::Restored); + restored = true; + assert_eq!(run().outcome, ClassificationRollbackOutcome::PreflightFailure); + assert_eq!(writes, 2); +} + +#[test] +fn rollback_reconciles_failed_write_without_guessing() { + for (current, expected_outcome) in [ + ("restored", (vec!["B"], None)), + ("unchanged", (vec![], None)), + ("indeterminate", (vec![], Some("B"))), + ] { + let receipt = applied_receipt(); + let mut reads = 0; + let result = execute_classification_rollback( + &receipt.rollback_operations, + |key| { + reads += 1; + let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + let reconciliation = reads > receipt.rollback_operations.len(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: if reconciliation { 45 } else { 44 }, + item_key: key.into(), + item_version: operation.item_version + u64::from(reconciliation), + collection_keys: if reconciliation && current == "restored" { operation.collection_keys.clone() } else if reconciliation && current == "indeterminate" { vec!["other".into()] } else { operation.expected_collection_keys.clone() }, + tags: if reconciliation && current == "restored" { operation.tags.clone() } else { operation.expected_tags.clone() }, + }) + }, + |_| Err::(()), + ); + assert_eq!(result.outcome, ClassificationRollbackOutcome::PartialFailure); + assert_eq!(result.restored_item_keys, expected_outcome.0); + assert_eq!(result.failed_item_key.as_deref(), Some("B")); + assert_eq!(result.indeterminate_item_key.as_deref(), expected_outcome.1); + assert_eq!(result.not_attempted_item_keys, ["A"]); + assert_eq!(result.remaining_operations.len(), if current == "restored" { 1 } else { 2 }); + } +} + +#[test] +fn rollback_receipts_are_secret_free_serializable_evidence() { + let receipt = applied_receipt(); + let json = serde_json::to_value(&receipt.rollback_operations).unwrap(); + let text = json.to_string(); + assert!(text.contains("server-1")); + assert!(text.contains("expected_collection_keys")); + assert!(!text.to_ascii_lowercase().contains("api_key")); +} From 3119722b4118e36cd3f2d537fd678aaa36ffa365 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:34:52 +0900 Subject: [PATCH 02/20] feat(zotero): execute verified rollback receipts --- crates/conceptweave-zotero/src/lib.rs | 218 +++++++++++++++++ .../tests/classification_rollback.rs | 227 ++++++++++++++++-- docs/PRD.md | 4 +- docs/TRD.md | 4 +- docs/adr/0007-reviewed-zotero-write-plan.md | 4 +- docs/product-technical-gap-baseline.md | 4 +- 6 files changed, 427 insertions(+), 34 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index fb9099b8..8f319ffc 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -827,16 +827,51 @@ pub struct ClassificationWriteRequest { /// A conditional inverse write created only after a verified successful write. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ClassificationRollbackOperation { + /// Local API server identity that produced the state to undo. + pub server_id: String, /// Stable Zotero item key. pub item_key: String, /// Post-write item revision required by a rollback adapter. pub item_version: u64, + /// Complete post-write collection state that must still be current. + pub expected_collection_keys: Vec, + /// Complete post-write typed-tag state that must still be current. + pub expected_tags: Vec, /// Complete collection state to restore. pub collection_keys: Vec, /// Complete typed-tag state to restore. pub tags: Vec, } +/// Observable result of one rollback execution attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClassificationRollbackOutcome { + /// Every inverse write was verified. + Restored, + /// No inverse write began because complete preflight could not be proven. + PreflightFailure, + /// An inverse write or its response failed after preflight. + PartialFailure, +} + +/// Secret-free evidence for restored, failed, indeterminate, and pending inverse writes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationRollbackReceipt { + /// Overall rollback outcome. + pub outcome: ClassificationRollbackOutcome, + /// Items whose restored state was verified, in receipt order. + pub restored_item_keys: Vec, + /// First item whose preflight, write, or response failed. + pub failed_item_key: Option, + /// Item whose state could not be proven after an unverifiable response. + pub indeterminate_item_key: Option, + /// Items whose inverse write was not attempted. + pub not_attempted_item_keys: Vec, + /// Operations still required for a complete restoration. + pub remaining_operations: Vec, +} + /// Observable result of a write-plan execution attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -1583,8 +1618,11 @@ pub fn execute_classification_write_plan( if let Some(state) = reconciled_state.filter(|_| reconciled_after) { applied_item_keys.push(operation.item_key.clone()); rollback_operations.push(ClassificationRollbackOperation { + server_id: server_id.to_owned(), item_key: operation.item_key.clone(), item_version: state.item_version, + expected_collection_keys: operation.after_collection_keys.clone(), + expected_tags: operation.after_tags.clone(), collection_keys: operation.rollback_collection_keys.clone(), tags: operation.rollback_tags.clone(), }); @@ -1601,8 +1639,11 @@ pub fn execute_classification_write_plan( current_library_version = state.library_version; applied_item_keys.push(operation.item_key.clone()); rollback_operations.push(ClassificationRollbackOperation { + server_id: server_id.to_owned(), item_key: operation.item_key.clone(), item_version: state.item_version, + expected_collection_keys: operation.after_collection_keys.clone(), + expected_tags: operation.after_tags.clone(), collection_keys: operation.rollback_collection_keys.clone(), tags: operation.rollback_tags.clone(), }); @@ -1634,6 +1675,147 @@ pub fn execute_classification_write_plan_with_zotero10( ) } +/// Executes verified inverse operations in their existing safe receipt order. +pub fn execute_classification_rollback( + operations: &[ClassificationRollbackOperation], + mut preflight: impl FnMut(&str) -> Result, + mut write_item: impl FnMut( + &ClassificationWriteRequest, + ) -> Result, +) -> ClassificationRollbackReceipt { + let mut preflight_states = Vec::with_capacity(operations.len()); + let mut library_version = None; + for operation in operations { + let Ok(state) = preflight(&operation.item_key) else { + return rollback_preflight_failure(operations, &operation.item_key); + }; + let same_library = library_version.is_none_or(|version| version == state.library_version); + if !same_library || !matches_rollback_current(&state, operation) { + return rollback_preflight_failure(operations, &operation.item_key); + } + library_version = Some(state.library_version); + preflight_states.push(state); + } + + let mut current_library_version = library_version.unwrap_or_default(); + let mut restored_item_keys = Vec::new(); + for (index, (operation, state)) in operations.iter().zip(preflight_states).enumerate() { + let request = ClassificationWriteRequest { + server_id: operation.server_id.clone(), + library_version: current_library_version, + item_key: operation.item_key.clone(), + item_version: state.item_version, + collection_keys: operation.collection_keys.clone(), + tags: operation.tags.clone(), + }; + let verified = write_item(&request) + .ok() + .filter(|state| matches_rollback_restored(state, current_library_version, operation)); + if let Some(state) = verified { + current_library_version = state.library_version; + restored_item_keys.push(operation.item_key.clone()); + continue; + } + + let reconciled = preflight(&operation.item_key).ok(); + let restored = reconciled.as_ref().is_some_and(|state| { + matches_rollback_restored(state, current_library_version, operation) + }); + let unchanged = reconciled.as_ref().is_some_and(|state| { + matches_rollback_current_at(state, current_library_version, operation) + }); + if restored { + restored_item_keys.push(operation.item_key.clone()); + } + let remaining_start = index + usize::from(restored); + return ClassificationRollbackReceipt { + outcome: ClassificationRollbackOutcome::PartialFailure, + restored_item_keys, + failed_item_key: Some(operation.item_key.clone()), + indeterminate_item_key: (!restored && !unchanged).then(|| operation.item_key.clone()), + not_attempted_item_keys: operations[index + 1..] + .iter() + .map(|operation| operation.item_key.clone()) + .collect(), + remaining_operations: operations[remaining_start..].to_vec(), + }; + } + ClassificationRollbackReceipt { + outcome: ClassificationRollbackOutcome::Restored, + restored_item_keys, + failed_item_key: None, + indeterminate_item_key: None, + not_attempted_item_keys: Vec::new(), + remaining_operations: Vec::new(), + } +} + +/// Executes rollback evidence through one server-bound Zotero 10 adapter. +pub fn execute_classification_rollback_with_zotero10( + operations: &[ClassificationRollbackOperation], + adapter: &Zotero10LocalAdapter, +) -> ClassificationRollbackReceipt { + execute_classification_rollback( + operations, + |item_key| adapter.get_item(item_key), + |request| adapter.write_item(request), + ) +} + +fn rollback_preflight_failure( + operations: &[ClassificationRollbackOperation], + failed_item_key: &str, +) -> ClassificationRollbackReceipt { + ClassificationRollbackReceipt { + outcome: ClassificationRollbackOutcome::PreflightFailure, + restored_item_keys: Vec::new(), + failed_item_key: Some(failed_item_key.to_owned()), + indeterminate_item_key: None, + not_attempted_item_keys: operations + .iter() + .map(|item| item.item_key.clone()) + .collect(), + remaining_operations: operations.to_vec(), + } +} + +fn matches_rollback_current( + state: &ClassificationItemState, + operation: &ClassificationRollbackOperation, +) -> bool { + matches_rollback_current_at(state, state.library_version, operation) +} + +fn matches_rollback_current_at( + state: &ClassificationItemState, + library_version: u64, + operation: &ClassificationRollbackOperation, +) -> bool { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { + state.server_id == operation.server_id + && state.library_version == library_version + && state.item_key == operation.item_key + && state.item_version == operation.item_version + && collections == operation.expected_collection_keys + && tags == operation.expected_tags + }) +} + +fn matches_rollback_restored( + state: &ClassificationItemState, + library_version: u64, + operation: &ClassificationRollbackOperation, +) -> bool { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { + state.server_id == operation.server_id + && state.library_version > library_version + && state.item_key == operation.item_key + && state.item_version > operation.item_version + && collections == operation.collection_keys + && tags == operation.tags + }) +} + fn matches_before_state( state: &ClassificationItemState, server_id: &str, @@ -2435,6 +2617,42 @@ mod tests { assert_eq!(server.join().unwrap().len(), 4); } + #[test] + fn approved_zotero10_adapter_executes_the_rollback_boundary() { + let operation = ClassificationRollbackOperation { + server_id: "server-10".into(), + item_key: "ABCD2345".into(), + item_version: 43, + expected_collection_keys: vec!["CDEF4567".into()], + expected_tags: vec![ItemTag { + tag: "classified".into(), + tag_type: None, + }], + collection_keys: vec!["BCDE3456".into()], + tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + }; + let before = library_response("server-10", 44); + let item_body = r#"{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["CDEF4567"],"tags":[{"tag":"classified"}]}}"#; + let item = raw_response(Some("server-10"), Some(43), item_body); + let after = library_response("server-10", 44); + let written = write_response("server-10", 45, 45); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + Box::leak(after.into_boxed_str()), + Box::leak(written.into_boxed_str()), + ]); + + let receipt = execute_classification_rollback_with_zotero10(&[operation], &transport(base)); + + assert_eq!(receipt.outcome, ClassificationRollbackOutcome::Restored); + assert_eq!(receipt.restored_item_keys, ["ABCD2345"]); + assert_eq!(server.join().unwrap().len(), 4); + } + #[test] fn zotero10_authorization_uses_exact_wire_contract_and_builds_adapter() { let body = r#"{"key":"0123456789abcdef0123456789abcdef","remember":true}"#; diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 0ced8270..c2d8b02c 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -1,8 +1,8 @@ use conceptweave_zotero::{ ClassificationItemState, ClassificationRollbackOutcome, ClassificationWriteRequest, - Disposition, ItemData, ItemTag, ReviewedClassificationChange, - ReviewedClassificationWriteSet, WriteMode, ZoteroItem, build_classification_write_plan, - classify_snapshot, execute_classification_rollback, execute_classification_write_plan, + Disposition, ItemData, ItemTag, ReviewedClassificationChange, ReviewedClassificationWriteSet, + WriteMode, ZoteroItem, build_classification_write_plan, classify_snapshot, + execute_classification_rollback, execute_classification_write_plan, }; fn tag(name: &str) -> ItemTag { @@ -84,7 +84,11 @@ fn applied_receipt() -> conceptweave_zotero::ClassificationWriteReceipt { execute_classification_write_plan( &plan, |key| { - let operation = plan.operations().iter().find(|item| item.item_key == key).unwrap(); + let operation = plan + .operations() + .iter() + .find(|item| item.item_key == key) + .unwrap(); Ok::<_, ()>(ClassificationItemState { server_id: "server-1".into(), library_version: 42, @@ -114,8 +118,14 @@ fn rollback_preflights_every_item_then_restores_in_receipt_order() { let receipt = applied_receipt(); assert_eq!(receipt.rollback_operations[0].item_key, "B"); assert_eq!(receipt.rollback_operations[0].server_id, "server-1"); - assert_eq!(receipt.rollback_operations[0].expected_collection_keys, ["evaluation"]); - assert_eq!(receipt.rollback_operations[0].expected_tags, [tag("Evaluation")]); + assert_eq!( + receipt.rollback_operations[0].expected_collection_keys, + ["evaluation"] + ); + assert_eq!( + receipt.rollback_operations[0].expected_tags, + [tag("Evaluation")] + ); let mut reads = Vec::new(); let mut writes = Vec::new(); @@ -123,7 +133,11 @@ fn rollback_preflights_every_item_then_restores_in_receipt_order() { &receipt.rollback_operations, |key| { reads.push(key.to_owned()); - let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); Ok::<_, ()>(ClassificationItemState { server_id: operation.server_id.clone(), library_version: 44, @@ -151,33 +165,50 @@ fn rollback_preflights_every_item_then_restores_in_receipt_order() { #[test] fn rollback_is_one_shot_and_second_execution_fails_before_write() { + use std::cell::Cell; + let receipt = applied_receipt(); - let mut restored = false; - let mut writes = 0; - let mut run = || { + let restored = Cell::new(false); + let writes = Cell::new(0); + let run = || { execute_classification_rollback( &receipt.rollback_operations, |key| { - let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); Ok::<_, ()>(ClassificationItemState { server_id: operation.server_id.clone(), - library_version: if restored { 46 } else { 44 }, + library_version: if restored.get() { 46 } else { 44 }, item_key: key.into(), - item_version: operation.item_version + u64::from(restored), - collection_keys: if restored { operation.collection_keys.clone() } else { operation.expected_collection_keys.clone() }, - tags: if restored { operation.tags.clone() } else { operation.expected_tags.clone() }, + item_version: operation.item_version + u64::from(restored.get()), + collection_keys: if restored.get() { + operation.collection_keys.clone() + } else { + operation.expected_collection_keys.clone() + }, + tags: if restored.get() { + operation.tags.clone() + } else { + operation.expected_tags.clone() + }, }) }, |request| { - writes += 1; + writes.set(writes.get() + 1); Ok::<_, ()>(written(request)) }, ) }; assert_eq!(run().outcome, ClassificationRollbackOutcome::Restored); - restored = true; - assert_eq!(run().outcome, ClassificationRollbackOutcome::PreflightFailure); - assert_eq!(writes, 2); + restored.set(true); + assert_eq!( + run().outcome, + ClassificationRollbackOutcome::PreflightFailure + ); + assert_eq!(writes.get(), 2); } #[test] @@ -193,25 +224,50 @@ fn rollback_reconciles_failed_write_without_guessing() { &receipt.rollback_operations, |key| { reads += 1; - let operation = receipt.rollback_operations.iter().find(|item| item.item_key == key).unwrap(); + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); let reconciliation = reads > receipt.rollback_operations.len(); Ok::<_, ()>(ClassificationItemState { server_id: operation.server_id.clone(), - library_version: if reconciliation { 45 } else { 44 }, + library_version: if reconciliation && current != "unchanged" { + 45 + } else { + 44 + }, item_key: key.into(), - item_version: operation.item_version + u64::from(reconciliation), - collection_keys: if reconciliation && current == "restored" { operation.collection_keys.clone() } else if reconciliation && current == "indeterminate" { vec!["other".into()] } else { operation.expected_collection_keys.clone() }, - tags: if reconciliation && current == "restored" { operation.tags.clone() } else { operation.expected_tags.clone() }, + item_version: operation.item_version + + u64::from(reconciliation && current != "unchanged"), + collection_keys: if reconciliation && current == "restored" { + operation.collection_keys.clone() + } else if reconciliation && current == "indeterminate" { + vec!["other".into()] + } else { + operation.expected_collection_keys.clone() + }, + tags: if reconciliation && current == "restored" { + operation.tags.clone() + } else { + operation.expected_tags.clone() + }, }) }, |_| Err::(()), ); - assert_eq!(result.outcome, ClassificationRollbackOutcome::PartialFailure); + assert_eq!( + result.outcome, + ClassificationRollbackOutcome::PartialFailure + ); assert_eq!(result.restored_item_keys, expected_outcome.0); assert_eq!(result.failed_item_key.as_deref(), Some("B")); assert_eq!(result.indeterminate_item_key.as_deref(), expected_outcome.1); assert_eq!(result.not_attempted_item_keys, ["A"]); - assert_eq!(result.remaining_operations.len(), if current == "restored" { 1 } else { 2 }); + assert_eq!( + result.remaining_operations.len(), + if current == "restored" { 1 } else { 2 } + ); } } @@ -224,3 +280,122 @@ fn rollback_receipts_are_secret_free_serializable_evidence() { assert!(text.contains("expected_collection_keys")); assert!(!text.to_ascii_lowercase().contains("api_key")); } + +#[test] +fn rollback_preflight_rejects_unreadable_mixed_or_mismatched_state() { + let receipt = applied_receipt(); + let failed = execute_classification_rollback( + &receipt.rollback_operations, + |_| Err::(()), + |_| -> Result { panic!("preflight must prevent writes") }, + ); + assert_eq!( + failed.outcome, + ClassificationRollbackOutcome::PreflightFailure + ); + + let mismatches: [fn(&mut ClassificationItemState); 6] = [ + |state| state.server_id = "other-server".into(), + |state| state.item_key = "other-item".into(), + |state| state.item_version += 1, + |state| state.collection_keys = vec!["other".into()], + |state| state.tags = vec![tag("Other")], + |state| state.collection_keys.push(" ".into()), + ]; + for mismatch in mismatches { + let failed = execute_classification_rollback( + &receipt.rollback_operations, + |key| { + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); + let mut state = ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: 44, + item_key: key.into(), + item_version: operation.item_version, + collection_keys: operation.expected_collection_keys.clone(), + tags: operation.expected_tags.clone(), + }; + if key == "B" { + mismatch(&mut state); + } + Ok::<_, ()>(state) + }, + |_| -> Result { panic!("preflight must prevent writes") }, + ); + assert_eq!( + failed.outcome, + ClassificationRollbackOutcome::PreflightFailure + ); + } + + let mixed = execute_classification_rollback( + &receipt.rollback_operations, + |key| { + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: if key == "B" { 44 } else { 45 }, + item_key: key.into(), + item_version: operation.item_version, + collection_keys: operation.expected_collection_keys.clone(), + tags: operation.expected_tags.clone(), + }) + }, + |_| -> Result { panic!("preflight must prevent writes") }, + ); + assert_eq!( + mixed.outcome, + ClassificationRollbackOutcome::PreflightFailure + ); +} + +#[test] +fn rollback_rejects_each_unverified_restoration_response() { + let receipt = applied_receipt(); + let mismatches: [fn(&mut ClassificationItemState); 6] = [ + |state| state.server_id = "other-server".into(), + |state| state.library_version = 44, + |state| state.item_key = "other-item".into(), + |state| state.item_version -= 1, + |state| state.collection_keys = vec!["other".into()], + |state| state.tags = vec![tag("Other")], + ]; + for mismatch in mismatches { + let failed = execute_classification_rollback( + &receipt.rollback_operations, + |key| { + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: 44, + item_key: key.into(), + item_version: operation.item_version, + collection_keys: operation.expected_collection_keys.clone(), + tags: operation.expected_tags.clone(), + }) + }, + |request| { + let mut state = written(request); + mismatch(&mut state); + Ok::<_, ()>(state) + }, + ); + assert_eq!( + failed.outcome, + ClassificationRollbackOutcome::PartialFailure + ); + assert_eq!(failed.indeterminate_item_key, None); + } +} diff --git a/docs/PRD.md b/docs/PRD.md index 74babf8a..5d03e7a9 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,9 +62,9 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. Execution-critical plan state is immutable outside the owner crate, so callers cannot turn a dry run into execution or alter validated operations. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. -For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions. Cross-item atomicity is not claimed. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to the server identity, proven post-write item revision, and complete expected post-write metadata. Rollback must preflight every receipt item at one current library version before its first inverse write, consume the existing receipt order, advance the library precondition only from a verified response, reconcile a failed response as restored, unchanged, or indeterminate, and retain every operation still needed. A second use of consumed evidence must fail before writing. Cross-item atomicity is not claimed. -The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. One public execution boundary connects this adapter to the reviewed plan without duplicating preflight, reconciliation, or rollback logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. +The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index da2ce7c0..74c92d7f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,6 +68,6 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core first reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work; already consumed evidence fails preflight on reuse. -The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. A narrow adapter execution function reuses the existing complete preflight, same-boundary reconciliation, and reverse rollback receipt instead of introducing a second execution path. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. +The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 51f8de7f..1dc0c901 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,13 +14,13 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. One public adapter execution boundary delegates to the existing reviewed execution core, preserving its complete preflight, reconciliation, and rollback receipt rather than creating parallel mutation logic. Static error categories cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first write, rollback verifies every item at one current library version; it then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate, with remaining operations retained. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences - Review and rollback semantics can be tested on Zotero 9 without changing the library. - Exact before-state checks prevent silent loss of unrelated collections or automatic-tag metadata. -- AC5 is implemented. AC6 now has deterministic preflight, partial-failure, rollback-receipt, and synthetic authenticated transport evidence. AC6 remains incomplete until approved live Zotero 10 write, partial-failure, and rollback behavior is verified. +- AC5 is implemented. AC6 now has deterministic write and rollback preflight, partial-failure reconciliation, secret-free receipts, and synthetic authenticated transport evidence. AC6 remains incomplete until approved live Zotero 10 write, partial-failure, and rollback behavior is verified. ## Alternatives considered diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d1451a32..1c65be5f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,11 +42,11 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d ### Zotero research classification slice -The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. A narrow successor execution boundary connects that adapter to the existing reviewed-plan preflight, reconciliation, and rollback receipt without duplicating mutation logic. Mock TCP fixtures only were used: no live prompt ran and no key is committed. +The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. Thin adapter boundaries connect the transport to generic reviewed-write and rollback executors without duplicating mutation logic. Rollback evidence now binds the server and complete expected post-write metadata; the executor preflights all items at one version, consumes reverse receipt order, reconciles restored, unchanged, and indeterminate failures, and returns secret-free remaining-work evidence. This is synthetic evidence only: no live prompt, write, or rollback ran and no key is committed. Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. A fixed-loopback Zotero 10 adapter now supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; mock TCP fixtures verify its exact wire contract and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate instead of falsely reversible, and reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From b588e964676c4b9fcfe0b50fb3081bbfbb799762 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:37:02 +0900 Subject: [PATCH 03/20] test(zotero): tighten rollback retry boundaries --- .../tests/classification_rollback.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index c2d8b02c..4c1bef7b 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -266,7 +266,7 @@ fn rollback_reconciles_failed_write_without_guessing() { assert_eq!(result.not_attempted_item_keys, ["A"]); assert_eq!( result.remaining_operations.len(), - if current == "restored" { 1 } else { 2 } + if current == "unchanged" { 2 } else { 1 } ); } } @@ -355,6 +355,22 @@ fn rollback_preflight_rejects_unreadable_mixed_or_mismatched_state() { mixed.outcome, ClassificationRollbackOutcome::PreflightFailure ); + + let mut cross_server = receipt.rollback_operations.clone(); + cross_server[1].server_id = "other-server".into(); + let failed = execute_classification_rollback( + &cross_server, + |_| -> Result { + panic!("mixed-server receipt must be rejected before reads") + }, + |_| -> Result { + panic!("mixed-server receipt must be rejected before writes") + }, + ); + assert_eq!( + failed.outcome, + ClassificationRollbackOutcome::PreflightFailure + ); } #[test] From 8d334a3bd1a38c930084d48085a41acd42a6df41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:37:28 +0900 Subject: [PATCH 04/20] fix(zotero): fail closed on unsafe rollback retry --- crates/conceptweave-zotero/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 8f319ffc..9aa7669a 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1683,6 +1683,14 @@ pub fn execute_classification_rollback( &ClassificationWriteRequest, ) -> Result, ) -> ClassificationRollbackReceipt { + if let Some(operation) = operations + .windows(2) + .find(|pair| pair[0].server_id != pair[1].server_id) + .map(|pair| &pair[1]) + { + return rollback_preflight_failure(operations, &operation.item_key); + } + let mut preflight_states = Vec::with_capacity(operations.len()); let mut library_version = None; for operation in operations { @@ -1727,7 +1735,7 @@ pub fn execute_classification_rollback( if restored { restored_item_keys.push(operation.item_key.clone()); } - let remaining_start = index + usize::from(restored); + let remaining_start = index + usize::from(restored || !unchanged); return ClassificationRollbackReceipt { outcome: ClassificationRollbackOutcome::PartialFailure, restored_item_keys, From af16e0d83a7b6ba38e1baa947c4e9f8110fef9f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:39:30 +0900 Subject: [PATCH 05/20] docs(zotero): define rollback retry safety --- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 5d03e7a9..d6ed0ac2 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,7 +62,7 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. Execution-critical plan state is immutable outside the owner crate, so callers cannot turn a dry run into execution or alter validated operations. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. -For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to the server identity, proven post-write item revision, and complete expected post-write metadata. Rollback must preflight every receipt item at one current library version before its first inverse write, consume the existing receipt order, advance the library precondition only from a verified response, reconcile a failed response as restored, unchanged, or indeterminate, and retain every operation still needed. A second use of consumed evidence must fail before writing. Cross-item atomicity is not claimed. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to the server identity, proven post-write item revision, and complete expected post-write metadata. Rollback must reject mixed-server evidence before reading, preflight every receipt item at one current library version before its first inverse write, consume the existing receipt order, advance the library precondition only from a verified response, and reconcile a failed response as restored, unchanged, or indeterminate. Only proven unchanged and untouched operations remain eligible for automatic retry; an indeterminate operation requires operator reconciliation. A second use of consumed evidence must fail before writing. Cross-item atomicity is not claimed. The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. diff --git a/docs/TRD.md b/docs/TRD.md index 74c92d7f..60d9931e 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,6 +68,6 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core first reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work; already consumed evidence fails preflight on reuse. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation requires operator reconciliation. Already consumed evidence fails preflight on reuse. The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 1dc0c901..a17889bf 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,7 +14,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first write, rollback verifies every item at one current library version; it then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate, with remaining operations retained. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate. Only a proven unchanged current operation and untouched later operations remain eligible for automatic retry; an indeterminate operation requires operator reconciliation. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1c65be5f..afdcad35 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -46,7 +46,7 @@ The successor authorization slice adds one-shot Zotero 10 Local API authorizatio Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate instead of falsely reversible, and reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate and excluded from automatic retry until operator reconciliation; reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 6683fbad98e3f8c160faf9ebac474fdb9148d093 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:40:21 +0900 Subject: [PATCH 06/20] test(zotero): preserve indeterminate rollback evidence --- .../conceptweave-zotero/tests/classification_rollback.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 4c1bef7b..33dde0d4 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -263,6 +263,13 @@ fn rollback_reconciles_failed_write_without_guessing() { assert_eq!(result.restored_item_keys, expected_outcome.0); assert_eq!(result.failed_item_key.as_deref(), Some("B")); assert_eq!(result.indeterminate_item_key.as_deref(), expected_outcome.1); + assert_eq!( + result + .indeterminate_operation + .as_ref() + .map(|operation| operation.item_key.as_str()), + expected_outcome.1 + ); assert_eq!(result.not_attempted_item_keys, ["A"]); assert_eq!( result.remaining_operations.len(), From e654eb7d0f2c00185e99ff60d5ce0cb28a372593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:41:46 +0900 Subject: [PATCH 07/20] fix(zotero): retain rollback reconciliation evidence --- crates/conceptweave-zotero/src/lib.rs | 10 ++++++++-- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 9aa7669a..f150fbac 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -866,9 +866,11 @@ pub struct ClassificationRollbackReceipt { pub failed_item_key: Option, /// Item whose state could not be proven after an unverifiable response. pub indeterminate_item_key: Option, + /// Complete operation retained for manual reconciliation of indeterminate state. + pub indeterminate_operation: Option, /// Items whose inverse write was not attempted. pub not_attempted_item_keys: Vec, - /// Operations still required for a complete restoration. + /// Proven unchanged and untouched operations eligible for automatic retry. pub remaining_operations: Vec, } @@ -1735,12 +1737,14 @@ pub fn execute_classification_rollback( if restored { restored_item_keys.push(operation.item_key.clone()); } + let indeterminate = !restored && !unchanged; let remaining_start = index + usize::from(restored || !unchanged); return ClassificationRollbackReceipt { outcome: ClassificationRollbackOutcome::PartialFailure, restored_item_keys, failed_item_key: Some(operation.item_key.clone()), - indeterminate_item_key: (!restored && !unchanged).then(|| operation.item_key.clone()), + indeterminate_item_key: indeterminate.then(|| operation.item_key.clone()), + indeterminate_operation: indeterminate.then(|| operation.clone()), not_attempted_item_keys: operations[index + 1..] .iter() .map(|operation| operation.item_key.clone()) @@ -1753,6 +1757,7 @@ pub fn execute_classification_rollback( restored_item_keys, failed_item_key: None, indeterminate_item_key: None, + indeterminate_operation: None, not_attempted_item_keys: Vec::new(), remaining_operations: Vec::new(), } @@ -1779,6 +1784,7 @@ fn rollback_preflight_failure( restored_item_keys: Vec::new(), failed_item_key: Some(failed_item_key.to_owned()), indeterminate_item_key: None, + indeterminate_operation: None, not_attempted_item_keys: operations .iter() .map(|item| item.item_key.clone()) diff --git a/docs/PRD.md b/docs/PRD.md index d6ed0ac2..1ee215b4 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,7 +62,7 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. Execution-critical plan state is immutable outside the owner crate, so callers cannot turn a dry run into execution or alter validated operations. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. -For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to the server identity, proven post-write item revision, and complete expected post-write metadata. Rollback must reject mixed-server evidence before reading, preflight every receipt item at one current library version before its first inverse write, consume the existing receipt order, advance the library precondition only from a verified response, and reconcile a failed response as restored, unchanged, or indeterminate. Only proven unchanged and untouched operations remain eligible for automatic retry; an indeterminate operation requires operator reconciliation. A second use of consumed evidence must fail before writing. Cross-item atomicity is not claimed. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to the server identity, proven post-write item revision, and complete expected post-write metadata. Rollback must reject mixed-server evidence before reading, preflight every receipt item at one current library version before its first inverse write, consume the existing receipt order, advance the library precondition only from a verified response, and reconcile a failed response as restored, unchanged, or indeterminate. Only proven unchanged and untouched operations remain eligible for automatic retry; an indeterminate operation is retained separately with complete reconciliation evidence for an operator. A second use of consumed evidence must fail before writing. Cross-item atomicity is not claimed. The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. diff --git a/docs/TRD.md b/docs/TRD.md index 60d9931e..89d93346 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,6 +68,6 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation requires operator reconciliation. Already consumed evidence fails preflight on reuse. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation and its complete metadata are retained separately for operator reconciliation. Already consumed evidence fails preflight on reuse. The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index a17889bf..a2896ec1 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,7 +14,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate. Only a proven unchanged current operation and untouched later operations remain eligible for automatic retry; an indeterminate operation requires operator reconciliation. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate. Only a proven unchanged current operation and untouched later operations remain eligible for automatic retry; an indeterminate operation retains the complete evidence required for operator reconciliation in a separate field. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index afdcad35..4c203189 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -46,7 +46,7 @@ The successor authorization slice adds one-shot Zotero 10 Local API authorizatio Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate and excluded from automatic retry until operator reconciliation; reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate with complete operation evidence retained separately and excluded from automatic retry until operator reconciliation; reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 9abc8620c278ec83bfed706cf63001bc0b3f2142 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:42:44 +0900 Subject: [PATCH 08/20] test(zotero): read complete mock requests --- crates/conceptweave-zotero/src/lib.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index f150fbac..4b01e460 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2487,10 +2487,31 @@ mod tests { .into_iter() .map(|response| { let (mut stream, _) = listener.accept().unwrap(); - let mut bytes = vec![0; 16 * 1024]; - let length = stream.read(&mut bytes).unwrap(); + let mut bytes = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let length = stream.read(&mut chunk).unwrap(); + bytes.extend_from_slice(&chunk[..length]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or_default(); + if bytes.len() >= header_end + 4 + content_length { + break; + } + } stream.write_all(response.as_bytes()).unwrap(); - String::from_utf8(bytes[..length].to_vec()).unwrap() + String::from_utf8(bytes).unwrap() }) .collect() }); From dc6ef4aa9de10816427184fe3706dda90eb9c544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:42:56 +0900 Subject: [PATCH 09/20] style(zotero): format mock request reader --- crates/conceptweave-zotero/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 4b01e460..34d4211e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2492,7 +2492,8 @@ mod tests { let mut chunk = [0; 4096]; let length = stream.read(&mut chunk).unwrap(); bytes.extend_from_slice(&chunk[..length]); - let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") + let Some(header_end) = + bytes.windows(4).position(|part| part == b"\r\n\r\n") else { continue; }; From 70c1a85e32a52022ba70a0cdad48544c4f44bf81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:43:57 +0900 Subject: [PATCH 10/20] test(zotero): exclude mock server plumbing from coverage --- crates/conceptweave-zotero/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 34d4211e..a68b8ee7 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2479,6 +2479,7 @@ mod tests { static LOCAL_API_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg_attr(coverage_nightly, coverage(off))] fn serve(responses: Vec<&'static str>) -> (String, std::thread::JoinHandle>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); From d605e1517d2838b077bfa18cfd5c15d5246ea1cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:56:48 +0900 Subject: [PATCH 11/20] test(research): adopt captured-source rollback fixtures Signed-off-by: Seongho Bae --- crates/conceptweave-zotero/tests/classification_rollback.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 33dde0d4..c7b6c8ca 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -19,6 +19,7 @@ fn plan() -> conceptweave_zotero::ClassificationWritePlan { 42, vec![ ZoteroItem { + source_record: None, key: "A".into(), version: 7, data: ItemData { @@ -32,6 +33,7 @@ fn plan() -> conceptweave_zotero::ClassificationWritePlan { }, }, ZoteroItem { + source_record: None, key: "B".into(), version: 9, data: ItemData { From e5f583a34725a53453cc3043108bed16ecdd0bd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:02:42 +0900 Subject: [PATCH 12/20] test(zotero): retain rollback review proposal binding --- crates/conceptweave-zotero/tests/classification_rollback.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index c7b6c8ca..eff5d9e6 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -56,6 +56,7 @@ fn plan() -> conceptweave_zotero::ClassificationWritePlan { library_version: report.library_version, rule_revision: report.rule_revision.into(), snapshot_digest: report.snapshot_digest.clone(), + proposal_digest: conceptweave_zotero::classification_proposal_digest(&report), snapshot_items: report.snapshot_items.clone(), changes: vec![ ReviewedClassificationChange { From 318b370d642670c21403ba60999fd3f8a6f40f87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:03:07 +0900 Subject: [PATCH 13/20] test(zotero): expose inverse write causal uncertainty --- crates/conceptweave-zotero/tests/classification_rollback.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index eff5d9e6..9fcfca4e 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -217,8 +217,8 @@ fn rollback_is_one_shot_and_second_execution_fails_before_write() { #[test] fn rollback_reconciles_failed_write_without_guessing() { for (current, expected_outcome) in [ - ("restored", (vec!["B"], None)), - ("unchanged", (vec![], None)), + ("restored", (vec![], Some("B"))), + ("unchanged", (vec![], Some("B"))), ("indeterminate", (vec![], Some("B"))), ] { let receipt = applied_receipt(); @@ -276,7 +276,7 @@ fn rollback_reconciles_failed_write_without_guessing() { assert_eq!(result.not_attempted_item_keys, ["A"]); assert_eq!( result.remaining_operations.len(), - if current == "unchanged" { 2 } else { 1 } + 1 ); } } From 4e28613197bb9ed9777aa9a4dfd4ee196880a336 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:03:34 +0900 Subject: [PATCH 14/20] fix(zotero): preserve uncertain inverse requests and observations --- crates/conceptweave-zotero/src/lib.rs | 29 +++++++++---------- .../tests/classification_rollback.rs | 5 +--- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 44a39784..5c0babe8 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -907,9 +907,13 @@ pub struct ClassificationRollbackReceipt { pub indeterminate_item_key: Option, /// Complete operation retained for manual reconciliation of indeterminate state. pub indeterminate_operation: Option, + /// Exact submitted inverse request; observations do not authorize its retry. + pub indeterminate_request: Option, + /// Complete readback after failure, retained without causal completion claims. + pub reconciliation_observation: Option, /// Items whose inverse write was not attempted. pub not_attempted_item_keys: Vec, - /// Proven unchanged and untouched operations eligible for automatic retry. + /// Untouched operations only; this audit list does not grant retry authority. pub remaining_operations: Vec, } @@ -1927,28 +1931,19 @@ pub fn execute_classification_rollback( } let reconciled = preflight(&operation.item_key).ok(); - let restored = reconciled.as_ref().is_some_and(|state| { - matches_rollback_restored(state, current_library_version, operation) - }); - let unchanged = reconciled.as_ref().is_some_and(|state| { - matches_rollback_current_at(state, current_library_version, operation) - }); - if restored { - restored_item_keys.push(operation.item_key.clone()); - } - let indeterminate = !restored && !unchanged; - let remaining_start = index + usize::from(restored || !unchanged); return ClassificationRollbackReceipt { outcome: ClassificationRollbackOutcome::PartialFailure, restored_item_keys, failed_item_key: Some(operation.item_key.clone()), - indeterminate_item_key: indeterminate.then(|| operation.item_key.clone()), - indeterminate_operation: indeterminate.then(|| operation.clone()), + indeterminate_item_key: Some(operation.item_key.clone()), + indeterminate_operation: Some(operation.clone()), + indeterminate_request: Some(request), + reconciliation_observation: reconciled, not_attempted_item_keys: operations[index + 1..] .iter() .map(|operation| operation.item_key.clone()) .collect(), - remaining_operations: operations[remaining_start..].to_vec(), + remaining_operations: operations[index + 1..].to_vec(), }; } ClassificationRollbackReceipt { @@ -1957,6 +1952,8 @@ pub fn execute_classification_rollback( failed_item_key: None, indeterminate_item_key: None, indeterminate_operation: None, + indeterminate_request: None, + reconciliation_observation: None, not_attempted_item_keys: Vec::new(), remaining_operations: Vec::new(), } @@ -1984,6 +1981,8 @@ fn rollback_preflight_failure( failed_item_key: Some(failed_item_key.to_owned()), indeterminate_item_key: None, indeterminate_operation: None, + indeterminate_request: None, + reconciliation_observation: None, not_attempted_item_keys: operations .iter() .map(|item| item.item_key.clone()) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 9fcfca4e..3a10bc4c 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -274,10 +274,7 @@ fn rollback_reconciles_failed_write_without_guessing() { expected_outcome.1 ); assert_eq!(result.not_attempted_item_keys, ["A"]); - assert_eq!( - result.remaining_operations.len(), - 1 - ); + assert_eq!(result.remaining_operations.len(), 1); } } From de407cf219086d26a0676eb8b1b016b1b8853fa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:04:13 +0900 Subject: [PATCH 15/20] test(zotero): type empty restored expectation explicitly --- crates/conceptweave-zotero/tests/classification_rollback.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 3a10bc4c..38b80512 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -217,7 +217,7 @@ fn rollback_is_one_shot_and_second_execution_fails_before_write() { #[test] fn rollback_reconciles_failed_write_without_guessing() { for (current, expected_outcome) in [ - ("restored", (vec![], Some("B"))), + ("restored", (Vec::::new(), Some("B"))), ("unchanged", (vec![], Some("B"))), ("indeterminate", (vec![], Some("B"))), ] { From ff66a5424618f5558c17910ccf74827e8af5a80f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:04:49 +0900 Subject: [PATCH 16/20] test(zotero): assert complete inverse request and readback --- .../tests/classification_rollback.rs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 38b80512..3afd12b3 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -223,6 +223,8 @@ fn rollback_reconciles_failed_write_without_guessing() { ] { let receipt = applied_receipt(); let mut reads = 0; + let mut submitted_request = None; + let mut observed_state = None; let result = execute_classification_rollback( &receipt.rollback_operations, |key| { @@ -233,7 +235,7 @@ fn rollback_reconciles_failed_write_without_guessing() { .find(|item| item.item_key == key) .unwrap(); let reconciliation = reads > receipt.rollback_operations.len(); - Ok::<_, ()>(ClassificationItemState { + let state = ClassificationItemState { server_id: operation.server_id.clone(), library_version: if reconciliation && current != "unchanged" { 45 @@ -255,9 +257,17 @@ fn rollback_reconciles_failed_write_without_guessing() { } else { operation.expected_tags.clone() }, - }) + }; + if reconciliation { + observed_state = Some(state.clone()); + } + Ok::<_, ()>(state) + }, + |request| { + assert!(submitted_request.is_none()); + submitted_request = Some(request.clone()); + Err::(()) }, - |_| Err::(()), ); assert_eq!( result.outcome, @@ -274,6 +284,10 @@ fn rollback_reconciles_failed_write_without_guessing() { expected_outcome.1 ); assert_eq!(result.not_attempted_item_keys, ["A"]); + assert!(submitted_request.is_some()); + assert!(observed_state.is_some()); + assert_eq!(result.indeterminate_request, submitted_request); + assert_eq!(result.reconciliation_observation, observed_state); assert_eq!(result.remaining_operations.len(), 1); } } From 006efd0a138636fed81eaa71ba3151b5f3fa60aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:05:26 +0900 Subject: [PATCH 17/20] test(zotero): retain uncertainty for invalid inverse responses --- crates/conceptweave-zotero/tests/classification_rollback.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 3afd12b3..d6f20b2a 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -433,6 +433,8 @@ fn rollback_rejects_each_unverified_restoration_response() { failed.outcome, ClassificationRollbackOutcome::PartialFailure ); - assert_eq!(failed.indeterminate_item_key, None); + assert_eq!(failed.indeterminate_item_key.as_deref(), Some("B")); + assert!(failed.restored_item_keys.is_empty()); + assert_eq!(failed.remaining_operations, receipt.rollback_operations[1..]); } } From 876cbfe8086e6c7ed5fc2f38e055b2096f29f759 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:08:05 +0900 Subject: [PATCH 18/20] refactor(zotero): remove obsolete rollback self-comparison --- crates/conceptweave-zotero/src/lib.rs | 9 --------- .../conceptweave-zotero/tests/classification_rollback.rs | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 5c0babe8..4e0556be 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1994,18 +1994,9 @@ fn rollback_preflight_failure( fn matches_rollback_current( state: &ClassificationItemState, operation: &ClassificationRollbackOperation, -) -> bool { - matches_rollback_current_at(state, state.library_version, operation) -} - -fn matches_rollback_current_at( - state: &ClassificationItemState, - library_version: u64, - operation: &ClassificationRollbackOperation, ) -> bool { normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { state.server_id == operation.server_id - && state.library_version == library_version && state.item_key == operation.item_key && state.item_version == operation.item_version && collections == operation.expected_collection_keys diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index d6f20b2a..4aed44d2 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -435,6 +435,9 @@ fn rollback_rejects_each_unverified_restoration_response() { ); assert_eq!(failed.indeterminate_item_key.as_deref(), Some("B")); assert!(failed.restored_item_keys.is_empty()); - assert_eq!(failed.remaining_operations, receipt.rollback_operations[1..]); + assert_eq!( + failed.remaining_operations, + receipt.rollback_operations[1..] + ); } } From b238bc6cef22220c2ab0545bcf04e8cdc23dcf57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:09:13 +0900 Subject: [PATCH 19/20] docs(zotero): distinguish rollback audit data from authority --- crates/conceptweave-zotero/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 4e0556be..facfbaed 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -863,7 +863,7 @@ pub struct ClassificationWriteRequest { pub tags: Vec, } -/// A conditional inverse write created only after a verified successful write. +/// Mutable inverse-write audit data; this value alone does not prove authority. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ClassificationRollbackOperation { /// Local API server identity that produced the state to undo. @@ -1880,7 +1880,7 @@ pub fn execute_classification_write_plan_with_zotero10( ) } -/// Executes verified inverse operations in their existing safe receipt order. +/// Executes caller-validated inverse operations; this primitive grants no authority. pub fn execute_classification_rollback( operations: &[ClassificationRollbackOperation], mut preflight: impl FnMut(&str) -> Result, @@ -1959,7 +1959,7 @@ pub fn execute_classification_rollback( } } -/// Executes rollback evidence through one server-bound Zotero 10 adapter. +/// Executes caller-validated inverses through an adapter, without issuing approval. pub fn execute_classification_rollback_with_zotero10( operations: &[ClassificationRollbackOperation], adapter: &Zotero10LocalAdapter, From 000b37bfb8b127ea4a2018f12ee52f302faf47b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:09:58 +0900 Subject: [PATCH 20/20] docs(research): record inverse uncertainty repair and open authority gates --- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 44 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index b29c3ea5..a4ba7004 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -80,7 +80,7 @@ Reviewed collection and tag changes default to a local dry-run plan. Each operat For execute-mode plans, the runtime must preflight every item before the first write and stop at the first failed or unverifiable response. Follow-up metadata reads are observations only: matching before or after values cannot establish whether the submitted request completed, terminated, or caused the observed change. A secret-free receipt retains the exact reviewed plan coordinates, proposal binding, submitted indeterminate request and optional observation. Dry-run receipts enumerate every planned item as untouched. Only directly verified successful write responses produce applied entries and reverse-ordered inverse operations; an unknown write produces neither retry nor rollback authority. Earlier verified operations remain recorded. Cross-item atomicity is not claimed. -Rollback must retain server-bound expected and restoration metadata, preflight every operation at one current library version, preserve receipt order and directly verified revision advancement. Uncertain original or inverse writes must not become successful recovery or retry authority. PR #20's current operation-slice API and failed-inverse inference require repair before approved live use. +Rollback must retain server-bound expected and restoration metadata, preflight every operation at one current library version, preserve receipt order and directly verified revision advancement. Uncertain original or inverse writes must not become successful recovery or retry authority. Failed-inverse inference is repaired locally: complete submitted requests and observations remain indeterminate. PR #20's operation-slice API still requires authoritative consumer integration preserving original-write scope before approved live use; an empty inverse list cannot establish that an unknown original write was recovered. The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. diff --git a/docs/TRD.md b/docs/TRD.md index 1d56b55b..88888dd4 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -109,7 +109,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. -PR #20 retains its rollback core and adapter: mixed-server rejection precedes reads; complete current-state checks precede inverse writes; directly verified responses advance the library version. Its existing failed-response readback still infers restored or unchanged state, and its operation-slice input does not carry original-write scope or independent execution authority. These are open repair findings, not authorized recovery contracts. Preserve original rollback functionality while repairing those boundaries. +PR #20 retains its rollback core and adapter: mixed-server rejection precedes reads; complete current-state checks precede inverse writes; only directly verified responses advance the library version. Every failed or invalid inverse response now remains indeterminate, retaining the complete operation, exact submitted request (including its library precondition), and optional complete readback. Matching restored or unchanged metadata does not prove causal completion or termination, and the failed inverse is absent from remaining work. Earlier directly verified restorations remain recorded; remaining operations are untouched only, not automatic retry authority. The operation-slice API still lacks original-write scope and independent authority; authoritative consumer adoption remains an open gate, including empty-slice and delayed-reconciliation handling. The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 72dc5a3b..f58a0a05 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -18,7 +18,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. Dry-run enumerates every operation as not attempted. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid response, a follow-up read is observation only: matching before-state cannot prove a delayed request terminated, and matching after-state or a newer revision cannot prove which writer caused it. The receipt keeps the exact submitted request and optional observation, always names that item as indeterminate, and creates no inverse for that unconfirmed write. Earlier directly verified applied items and their inverse coordinates remain intact. The API key remains adapter-owned. Cross-item transactionality is not claimed, and source records and attachments are never deleted. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable response is re-read once and classified as restored, unchanged, or indeterminate. Only a proven unchanged current operation and untouched later operations remain eligible for automatic retry; an indeterminate operation retains the complete evidence required for operator reconciliation in a separate field. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable inverse response is re-read only as observation and always remains indeterminate. Its exact submitted request, full operation and optional observation are retained; matching metadata cannot prove completion or termination. Only untouched operations remain listed, without automatic retry authority. Public operation DTOs and empty operation slices are not complete original-write scope or independent approval; authoritative consumer wrappers must preserve those boundaries before live use. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b53c6f97..e55d8112 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,50 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #20 rollback uncertainty repair (2026-09-06) + +Untouched `a03a7248c894a1e0765968ddf58514d98c517da3` passed 116 tests/21 suites. +Normal merge `c53e36b` retains it and PR #19 `6dc6176`; conflicts preserve the +original rollback functionality and corrected forward-write uncertainty. Missing +required review proposal binding was repaired in test fixture `e5f583a`. + +Independent review found the inverse executor repeated the forward-write causal +error: matching restored metadata implied completion, while unchanged metadata +cleared uncertainty and re-enqueued the failed inverse. Initial `318b370` RED had +an empty-vector type error and is not behavioral evidence. Independent test-only +`c0fc17ba5262882662b487114d24c75e7ffb5d3d` against unchanged `e5f583a` compiled, +then failed one of six tests: observed restored keys `[B]` versus expected empty. +Its worktree `/private/tmp/conceptweave-rollback-red.O5zdby` and log +`/tmp/conceptweave-pr20-causal-independent-red.log` are preserved. + +Owner fix `4e28613` always retains failed or invalid inverse attempts as unknown, +with complete operation, exact submitted request and optional readback. Only +directly verified earlier restorations remain restored; remaining operations +contain untouched work only and confer no retry authority. `ff66a54` compares +complete request/observation in all three retained readback scenarios; `006efd0` +corrects the six invalid-response cases. Coverage found an obsolete self-comparison +after inference removal; `876cbfe` removes it without weakening shared-library, +server, metadata or item-revision preflight. Independent final review confirmed +these checks remain intact. `b238bc6` corrects misleading authority docstrings. + +At `876cbfe`, 139 tests/21 suites including three doctests, strict Clippy, +warnings-denied rustdoc and unchanged coverage gate pass: 262/262 functions, +2332/2332 normalized regions, 394/394 normalized branches. Raw LLVM remains +3007/3067 lines, 4525/4622 regions and 350/394 branches, not 100%. Logs use +`/tmp/conceptweave-pr20-causal-` with `verified.log`, `clippy-verified.log`, +`rustdoc-verified.log` and `coverage-verified.log`; docstring-only follow-up uses +`release-check.log` and `rustdoc-release-check.log` (not a release claim). + +PRD/TRD/Proposed ADR explicitly keep detached operation DTO authority and complete +original-write scope open. Empty inverse lists do not prove recovery of an unknown +original write; existing authoritative successor wrappers must be adopted rather +than inventing a competing capability layer. PR #21 delayed reconciliation +`09c84e4cdb1393a5e450f5200b87f292eeea956f` repeats the causal inference and is next. +No fresh native visual evidence: reinspection again encountered the locked Mac. +Historical 3,719 displayed items are not classification proof. Decisions/approvals +remain 0/3,715 plus four unresolved sources; no real authorization, mutation, +recovery, protected merge or release occurred. Root adoption remains outstanding. + ### PR #19 approved execution successor verification (2026-09-06) Baseline `62c19ee23e3c827bc7db15c79f4755ff040489e9` passed 109 tests/20 suites.