diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index e90ade25..facfbaed 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -863,19 +863,60 @@ 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. + 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, + /// 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, + /// Untouched operations only; this audit list does not grant retry authority. + pub remaining_operations: Vec, +} + /// Observable result of a write-plan execution attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -1792,8 +1833,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(), }); @@ -1836,6 +1880,145 @@ pub fn execute_classification_write_plan_with_zotero10( ) } +/// Executes caller-validated inverse operations; this primitive grants no authority. +pub fn execute_classification_rollback( + operations: &[ClassificationRollbackOperation], + mut preflight: impl FnMut(&str) -> Result, + mut write_item: impl FnMut( + &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 { + 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(); + return ClassificationRollbackReceipt { + outcome: ClassificationRollbackOutcome::PartialFailure, + restored_item_keys, + failed_item_key: Some(operation.item_key.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[index + 1..].to_vec(), + }; + } + ClassificationRollbackReceipt { + outcome: ClassificationRollbackOutcome::Restored, + restored_item_keys, + 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(), + } +} + +/// Executes caller-validated inverses through an adapter, without issuing approval. +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, + indeterminate_operation: None, + indeterminate_request: None, + reconciliation_observation: 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 { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { + state.server_id == operation.server_id + && 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, @@ -2607,6 +2790,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(); @@ -2617,25 +2801,27 @@ mod tests { let (mut stream, _) = listener.accept().unwrap(); let mut bytes = Vec::new(); loop { - let mut buffer = [0; 4096]; - let length = stream.read(&mut buffer).unwrap(); + let mut chunk = [0; 4096]; + let length = stream.read(&mut chunk).unwrap(); assert_ne!(length, 0); - bytes.extend_from_slice(&buffer[..length]); - if let Some(header_end) = + bytes.extend_from_slice(&chunk[..length]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") - { - let headers = std::str::from_utf8(&bytes[..header_end]).unwrap(); - let body_length = headers - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length: ") - .map(|value| value.parse::().unwrap()) - }) - .unwrap_or(0); - if bytes.len() >= header_end + 4 + body_length { - break; - } + 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(); @@ -2792,6 +2978,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 new file mode 100644 index 00000000..4aed44d2 --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -0,0 +1,443 @@ +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 { + source_record: None, + 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 { + source_record: None, + 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(), + proposal_digest: conceptweave_zotero::classification_proposal_digest(&report), + 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() { + use std::cell::Cell; + + let receipt = applied_receipt(); + 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(); + Ok::<_, ()>(ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: if restored.get() { 46 } else { 44 }, + item_key: key.into(), + 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.set(writes.get() + 1); + Ok::<_, ()>(written(request)) + }, + ) + }; + assert_eq!(run().outcome, ClassificationRollbackOutcome::Restored); + restored.set(true); + assert_eq!( + run().outcome, + ClassificationRollbackOutcome::PreflightFailure + ); + assert_eq!(writes.get(), 2); +} + +#[test] +fn rollback_reconciles_failed_write_without_guessing() { + for (current, expected_outcome) in [ + ("restored", (Vec::::new(), Some("B"))), + ("unchanged", (vec![], Some("B"))), + ("indeterminate", (vec![], Some("B"))), + ] { + 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| { + reads += 1; + let operation = receipt + .rollback_operations + .iter() + .find(|item| item.item_key == key) + .unwrap(); + let reconciliation = reads > receipt.rollback_operations.len(); + let state = ClassificationItemState { + server_id: operation.server_id.clone(), + library_version: if reconciliation && current != "unchanged" { + 45 + } else { + 44 + }, + item_key: key.into(), + 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() + }, + }; + if reconciliation { + observed_state = Some(state.clone()); + } + Ok::<_, ()>(state) + }, + |request| { + assert!(submitted_request.is_none()); + submitted_request = Some(request.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 + .indeterminate_operation + .as_ref() + .map(|operation| operation.item_key.as_str()), + 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); + } +} + +#[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")); +} + +#[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 + ); + + 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] +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.as_deref(), Some("B")); + assert!(failed.restored_item_keys.is_empty()); + assert_eq!( + failed.remaining_operations, + receipt.rollback_operations[1..] + ); + } +} diff --git a/docs/PRD.md b/docs/PRD.md index 867b24ba..a4ba7004 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -80,7 +80,9 @@ 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. -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. +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. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, 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 ad2838c9..88888dd4 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -109,6 +109,8 @@ 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. -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 is exactly parseable 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. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. +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. Loopback pinning, redirect rejection, and `Zotero-Server-ID` continuity checks do not encrypt HTTP traffic carrying `Zotero-API-Key` and do not authenticate the local peer before that key is transmitted. `Zotero-Server-ID` is not cryptographic server authentication. Under the currently documented Zotero Local API there is no HTTPS or OS-authenticated IPC write endpoint for ConceptWeave to substitute. A hostile same-host process that can observe, bind, or interpose on the loopback endpoint therefore remains inside the unresolved credential-confidentiality threat boundary. As recorded in `THREAT_MODEL.md`, mock/local orchestration evidence is allowed, but enterprise-secure live write-back remains fail closed until Zotero provides a protected transport or an explicit product-security/governance decision narrows the supported threat model and accepts the residual same-host risk. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 1fcb7850..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. 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 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 @@ -50,7 +50,7 @@ adopt the required field without deriving fresh authority from serialized plans. - 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 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.