diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cca6ba..f830533a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to ConceptWeave are documented here. ### Fixed +- Failed write responses no longer turn later observations into proof of completion or permission to undo another change; the submitted request and unresolved result remain available for investigation. +- Every execution outcome retains the identity of the reviewed supporting evidence. + - Research change plans reject changed supporting evidence and incomplete inventories before approval, and preserve the identity of the reviewed evidence. - Duplicate review rejects incomplete source inventories and changed supporting evidence before approval, while retaining reversible identity mappings. diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 7a2ad600..fadb8dcd 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -373,30 +373,160 @@ pub struct ClassificationWriteOperation { } /// Local-only, snapshot-bound plan for reviewed Zotero classification writes. +/// +/// Execution-critical fields are read-only outside this crate, so callers cannot +/// mutate a verified plan before passing it to the execution boundary. +/// +/// ```compile_fail +/// use conceptweave_zotero::{ClassificationWritePlan, WriteMode}; +/// fn forge(plan: &mut ClassificationWritePlan) { +/// plan.mode = WriteMode::Execute; +/// } +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ClassificationWritePlan { /// Requested write behavior; dry-run is the default. - pub mode: WriteMode, + mode: WriteMode, /// Opaque review receipt identifier. - pub review_id: String, + review_id: String, /// Opaque governance authority receipt. - pub authority_receipt: String, + authority_receipt: String, /// Exact Local API server identity. - pub server_id: Option, + server_id: Option, /// Exact Zotero version used to establish execute eligibility. - pub zotero_version: String, + zotero_version: String, /// Exact library-version precondition. - pub library_version: u64, + library_version: u64, /// Exact classifier revision. - pub rule_revision: String, + rule_revision: String, /// Exact raw-snapshot digest. - pub snapshot_digest: String, + snapshot_digest: String, /// Content identity retained from the independently verified review. - pub proposal_digest: String, + proposal_digest: String, /// Deterministically ordered item operations. - pub operations: Vec, + operations: Vec, /// Classification writes never delete source records or attachments. - pub source_records_preserved: bool, + source_records_preserved: bool, +} + +impl ClassificationWritePlan { + /// Returns the requested dry-run or execute behavior. + pub const fn mode(&self) -> WriteMode { + self.mode + } + + /// Returns the exact library revision used by every initial preflight. + pub const fn library_version(&self) -> u64 { + self.library_version + } + + /// Returns the deterministic reviewed operations. + pub fn operations(&self) -> &[ClassificationWriteOperation] { + &self.operations + } + + /// Confirms that the plan contains metadata changes only. + pub const fn source_records_preserved(&self) -> bool { + self.source_records_preserved + } +} + +/// Complete item state observed at the Local API write boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationItemState { + /// Local API server identity that produced this state. + pub server_id: String, + /// Library revision that produced this state. + pub library_version: u64, + /// Stable Zotero item key. + pub item_key: String, + /// Current optimistic item revision. + pub item_version: u64, + /// Complete collection state. + pub collection_keys: Vec, + /// Complete typed-tag state. + pub tags: Vec, +} + +/// One conditional complete-state replacement passed to an authenticated adapter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationWriteRequest { + /// Expected Local API server identity. + pub server_id: String, + /// Current library revision precondition. + pub library_version: u64, + /// Stable Zotero item key. + pub item_key: String, + /// Current item revision precondition. + pub item_version: u64, + /// Complete collection replacement. + pub collection_keys: Vec, + /// Complete typed-tag replacement. + pub tags: Vec, +} + +/// A conditional inverse write created only after a verified successful write. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationRollbackOperation { + /// Stable Zotero item key. + pub item_key: String, + /// Post-write item revision required by a rollback adapter. + pub item_version: u64, + /// Complete collection state to restore. + pub collection_keys: Vec, + /// Complete typed-tag state to restore. + pub tags: Vec, +} + +/// Observable result of a write-plan execution attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClassificationWriteOutcome { + /// The plan intentionally made no Local API calls. + DryRun, + /// Every requested item replacement was verified. + Applied, + /// No write began because a complete preflight could not be proven. + PreflightFailure, + /// A write or its response failed after preflight. + PartialFailure, +} + +/// Secret-free evidence for applied, failed, pending, and reversible writes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationWriteReceipt { + /// Opaque review receipt identifier bound to this attempt. + pub review_id: String, + /// Opaque governance authority receipt bound to this attempt. + pub authority_receipt: String, + /// Exact Local API server identity bound to this attempt. + pub server_id: Option, + /// Exact Zotero version bound to this attempt. + pub zotero_version: String, + /// Exact library-version precondition bound to this attempt. + pub library_version: u64, + /// Exact classifier revision bound to this attempt. + pub rule_revision: String, + /// Exact raw-snapshot digest bound to this attempt. + pub snapshot_digest: String, + /// Verified proposal and retained-source identity bound to this attempt. + pub proposal_digest: String, + /// Overall execution outcome. + pub outcome: ClassificationWriteOutcome, + /// Items whose post-write state was verified, in application order. + pub applied_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 write response. + pub indeterminate_item_key: Option, + /// Exact submitted request whose completion is unknown; audit evidence, not retry authority. + pub indeterminate_request: Option, + /// Subsequent observation, if available; it cannot prove causal completion. + pub reconciliation_observation: Option, + /// Items whose write was not attempted. + pub not_attempted_item_keys: Vec, + /// Verified inverse operations in safe reverse application order. + pub rollback_operations: Vec, } /// A fail-closed reviewed write-plan contract violation. @@ -1181,6 +1311,209 @@ where }) } +/// Executes a reviewed plan through caller-owned authenticated Local API functions. +/// +/// Every item is preflighted before the first write. Adapter errors are deliberately +/// reduced to secret-free receipt states instead of being serialized. +pub fn execute_classification_write_plan( + plan: &ClassificationWritePlan, + mut preflight: impl FnMut(&str) -> Result, + mut write_item: impl FnMut( + &ClassificationWriteRequest, + ) -> Result, +) -> ClassificationWriteReceipt { + if plan.mode == WriteMode::DryRun { + return ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + zotero_version: plan.zotero_version.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), + proposal_digest: plan.proposal_digest.clone(), + outcome: ClassificationWriteOutcome::DryRun, + applied_item_keys: Vec::new(), + failed_item_key: None, + indeterminate_item_key: None, + indeterminate_request: None, + reconciliation_observation: None, + not_attempted_item_keys: plan + .operations + .iter() + .map(|operation| operation.item_key.clone()) + .collect(), + rollback_operations: Vec::new(), + }; + } + let server_id = plan + .server_id + .as_deref() + .filter(|server_id| !server_id.trim().is_empty()) + .expect("execute plans are built with a nonblank server identity"); + + for operation in &plan.operations { + let Ok(state) = preflight(&operation.item_key) else { + return preflight_failure_receipt(plan, Some(&operation.item_key)); + }; + if !matches_before_state(&state, server_id, plan.library_version, operation) { + return preflight_failure_receipt(plan, Some(&operation.item_key)); + } + } + + let mut current_library_version = plan.library_version; + let mut applied_item_keys = Vec::new(); + let mut rollback_operations = Vec::new(); + for (operation_index, operation) in plan.operations.iter().enumerate() { + let request = ClassificationWriteRequest { + server_id: server_id.to_owned(), + library_version: current_library_version, + item_key: operation.item_key.clone(), + item_version: operation.item_version, + collection_keys: operation.after_collection_keys.clone(), + tags: operation.after_tags.clone(), + }; + let response = write_item(&request); + let verified_state = response.ok().filter(|state| { + matches_after_state(state, server_id, current_library_version, operation) + }); + let Some(state) = verified_state else { + let reconciled_state = preflight(&operation.item_key).ok(); + // A delayed request or concurrent writer can explain any observed state. + // Retain the observation without granting completion, retry, or inverse authority. + rollback_operations.reverse(); + return partial_failure_receipt( + plan, + operation_index, + applied_item_keys, + rollback_operations, + request, + reconciled_state, + ); + }; + current_library_version = state.library_version; + applied_item_keys.push(operation.item_key.clone()); + rollback_operations.push(ClassificationRollbackOperation { + item_key: operation.item_key.clone(), + item_version: state.item_version, + collection_keys: operation.rollback_collection_keys.clone(), + tags: operation.rollback_tags.clone(), + }); + } + rollback_operations.reverse(); + ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + zotero_version: plan.zotero_version.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), + proposal_digest: plan.proposal_digest.clone(), + outcome: ClassificationWriteOutcome::Applied, + applied_item_keys, + failed_item_key: None, + indeterminate_item_key: None, + indeterminate_request: None, + reconciliation_observation: None, + not_attempted_item_keys: Vec::new(), + rollback_operations, + } +} + +fn matches_before_state( + state: &ClassificationItemState, + server_id: &str, + library_version: u64, + operation: &ClassificationWriteOperation, +) -> bool { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { + state.server_id == server_id + && state.library_version == library_version + && state.item_key == operation.item_key + && state.item_version == operation.item_version + && collections == operation.before_collection_keys + && tags == operation.before_tags + }) +} + +fn matches_after_state( + state: &ClassificationItemState, + server_id: &str, + library_version: u64, + operation: &ClassificationWriteOperation, +) -> bool { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| { + state.server_id == server_id + && state.library_version > library_version + && state.item_key == operation.item_key + && state.item_version > operation.item_version + && collections == operation.after_collection_keys + && tags == operation.after_tags + }) +} + +fn preflight_failure_receipt( + plan: &ClassificationWritePlan, + failed_item_key: Option<&str>, +) -> ClassificationWriteReceipt { + ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + zotero_version: plan.zotero_version.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), + proposal_digest: plan.proposal_digest.clone(), + outcome: ClassificationWriteOutcome::PreflightFailure, + applied_item_keys: Vec::new(), + failed_item_key: failed_item_key.map(str::to_owned), + indeterminate_item_key: None, + indeterminate_request: None, + reconciliation_observation: None, + not_attempted_item_keys: plan + .operations + .iter() + .map(|operation| operation.item_key.clone()) + .collect(), + rollback_operations: Vec::new(), + } +} + +fn partial_failure_receipt( + plan: &ClassificationWritePlan, + failed_index: usize, + applied_item_keys: Vec, + rollback_operations: Vec, + indeterminate_request: ClassificationWriteRequest, + reconciliation_observation: Option, +) -> ClassificationWriteReceipt { + ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + zotero_version: plan.zotero_version.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), + proposal_digest: plan.proposal_digest.clone(), + outcome: ClassificationWriteOutcome::PartialFailure, + applied_item_keys, + failed_item_key: Some(plan.operations[failed_index].item_key.clone()), + indeterminate_item_key: Some(indeterminate_request.item_key.clone()), + indeterminate_request: Some(indeterminate_request), + reconciliation_observation, + not_attempted_item_keys: plan + .operations + .iter() + .skip(failed_index + 1) + .map(|operation| operation.item_key.clone()) + .collect(), + rollback_operations, + } +} + /// Failure raised when a bounded, immutable Local API read cannot be proven. #[derive(Debug)] pub enum ReadError { diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 567c8b40..86ac1ec4 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -1,7 +1,8 @@ use conceptweave_zotero::{ - Disposition, ItemData, ItemTag, ReviewedClassificationChange, ReviewedClassificationWriteSet, - WriteMode, WritePlanError, ZoteroItem, build_classification_write_plan, - classification_proposal_digest, classify_snapshot, + ClassificationItemState, ClassificationWriteOutcome, Disposition, ItemData, ItemTag, + ReviewedClassificationChange, ReviewedClassificationWriteSet, WriteMode, WritePlanError, + ZoteroItem, build_classification_write_plan, classification_proposal_digest, classify_snapshot, + execute_classification_write_plan, }; #[test] @@ -64,7 +65,10 @@ fn write_scope_requires_binding_and_independent_approval() { set == &approved }) .unwrap(); - assert_eq!(plan.proposal_digest, approved.proposal_digest); + assert_eq!( + serde_json::to_value(&plan).unwrap()["proposal_digest"], + approved.proposal_digest + ); report.classified_items[0] .title @@ -89,6 +93,318 @@ fn tag(name: &str, tag_type: Option) -> ItemTag { } } +#[test] +fn execution_preflights_every_item_and_returns_reversible_partial_failure() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let mut preflighted = Vec::new(); + let mut written = Vec::new(); + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + preflighted.push(item_key.to_owned()); + let operation = plan + .operations() + .iter() + .find(|operation| operation.item_key == item_key) + .unwrap(); + Ok::<_, ()>(ClassificationItemState { + server_id: "server-1".into(), + library_version: if preflighted.len() > plan.operations().len() { + 43 + } else { + 42 + }, + item_key: item_key.into(), + item_version: operation.item_version, + collection_keys: operation.before_collection_keys.clone(), + tags: operation.before_tags.clone(), + }) + }, + |request| { + written.push(request.item_key.clone()); + if request.item_key == "B" { + return Err(()); + } + Ok(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(), + }) + }, + ); + + assert_eq!(preflighted, ["A", "B", "B"]); + assert_eq!(written, ["A", "B"]); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.failed_item_key.as_deref(), Some("B")); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("B")); + assert!(receipt.not_attempted_item_keys.is_empty()); + assert_eq!(receipt.applied_item_keys, ["A"]); + assert_eq!(receipt.rollback_operations.len(), 1); + assert_eq!(receipt.rollback_operations[0].item_key, "A"); + assert_eq!(receipt.rollback_operations[0].item_version, 8); + assert_eq!( + receipt.rollback_operations[0].collection_keys, + Vec::::new() + ); + assert!(receipt.rollback_operations[0].tags.is_empty()); +} + +#[test] +fn dry_run_execution_never_calls_the_write_boundary() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::DryRun, |_| true) + .unwrap(); + let receipt = execute_classification_write_plan( + &plan, + |_| -> Result { panic!("dry-run must not preflight") }, + |_| -> Result { panic!("dry-run must not write") }, + ); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::DryRun); + assert!(receipt.applied_item_keys.is_empty()); + assert_eq!(receipt.indeterminate_item_key, None); + assert!(receipt.rollback_operations.is_empty()); +} + +#[test] +fn matching_observation_does_not_prove_a_lost_write_completed() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let mut b_reads = 0; + let mut submitted_requests = Vec::new(); + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + if item_key == "B" { + b_reads += 1; + if b_reads == 2 { + let operation = &plan.operations()[1]; + return Ok::<_, ()>(ClassificationItemState { + server_id: "server-1".into(), + library_version: 44, + item_key: "B".into(), + item_version: 10, + collection_keys: operation.after_collection_keys.clone(), + tags: operation.after_tags.clone(), + }); + } + } + Ok::<_, ()>(preflight_state(&plan, item_key)) + }, + |request| { + submitted_requests.push(request.clone()); + if request.item_key == "B" { + Err(()) + } else { + Ok(applied_state(request)) + } + }, + ); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.failed_item_key.as_deref(), Some("B")); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("B")); + assert_eq!(receipt.applied_item_keys, ["A"]); + assert_eq!(receipt.rollback_operations.len(), 1); + assert_eq!(receipt.rollback_operations[0].item_key, "A"); + assert_eq!(receipt.rollback_operations[0].item_version, 8); + let audit = serde_json::to_value(&receipt).unwrap(); + assert_eq!( + receipt.indeterminate_request.as_ref(), + submitted_requests.last() + ); + assert_eq!(audit["indeterminate_request"]["item_key"], "B"); + assert_eq!(audit["indeterminate_request"]["library_version"], 43); + assert_eq!(audit["indeterminate_request"]["item_version"], 9); + assert_eq!(audit["reconciliation_observation"]["item_version"], 10); +} + +#[test] +fn execution_names_an_item_when_failed_write_reconciliation_is_unavailable() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let mut reads = 0; + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + reads += 1; + if reads > plan.operations().len() { + Err(()) + } else { + Ok(preflight_state(&plan, item_key)) + } + }, + |_| Err::(()), + ); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.failed_item_key.as_deref(), Some("A")); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("A")); + assert_eq!( + receipt.indeterminate_request.as_ref().unwrap().item_key, + "A" + ); + assert!(receipt.reconciliation_observation.is_none()); + assert!(receipt.rollback_operations.is_empty()); + assert_eq!(receipt.not_attempted_item_keys, ["B"]); +} + +fn preflight_state( + plan: &conceptweave_zotero::ClassificationWritePlan, + item_key: &str, +) -> ClassificationItemState { + let operation = plan + .operations() + .iter() + .find(|operation| operation.item_key == item_key) + .unwrap(); + ClassificationItemState { + server_id: "server-1".into(), + library_version: plan.library_version(), + item_key: item_key.into(), + item_version: operation.item_version, + collection_keys: operation.before_collection_keys.clone(), + tags: operation.before_tags.clone(), + } +} + +#[test] +fn execution_fails_closed_for_each_preflight_mismatch() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let mismatches: [fn(&mut ClassificationItemState); 6] = [ + |state| state.server_id = "other-server".into(), + |state| state.library_version += 1, + |state| state.item_key = "other-item".into(), + |state| state.item_version += 1, + |state| state.collection_keys = vec!["other-collection".into()], + |state| state.tags = vec![tag("Other", None)], + ]; + for mismatch in mismatches { + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + let mut state = preflight_state(&plan, item_key); + if item_key == "A" { + mismatch(&mut state); + } + Ok::<_, ()>(state) + }, + |_| -> Result { panic!("preflight must finish first") }, + ); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure + ); + assert_eq!(receipt.failed_item_key.as_deref(), Some("A")); + assert_eq!(receipt.not_attempted_item_keys, ["A", "B"]); + } + + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + let mut state = preflight_state(&plan, item_key); + state.collection_keys.push(" ".into()); + Ok::<_, ()>(state) + }, + |_| -> Result { panic!("invalid metadata must fail first") }, + ); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure + ); + + let receipt = execute_classification_write_plan( + &plan, + |_| Err::(()), + |_| -> Result { + panic!("failed preflight must prevent writes") + }, + ); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure + ); +} + +fn applied_state( + request: &conceptweave_zotero::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 execution_verifies_each_write_response_and_success_receipt() { + let report = classification_report("10.0.0"); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let mismatches: [fn(&mut ClassificationItemState); 6] = [ + |state| state.server_id = "other-server".into(), + |state| state.library_version -= 1, + |state| state.item_key = "other-item".into(), + |state| state.item_version -= 1, + |state| state.collection_keys = vec!["other-collection".into()], + |state| state.tags = vec![tag("Other", None)], + ]; + for mismatch in mismatches { + let receipt = execute_classification_write_plan( + &plan, + |item_key| Ok::<_, ()>(preflight_state(&plan, item_key)), + |request| { + let mut state = applied_state(request); + mismatch(&mut state); + Ok::<_, ()>(state) + }, + ); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.failed_item_key.as_deref(), Some("A")); + assert_eq!(receipt.not_attempted_item_keys, ["B"]); + } + + let receipt = execute_classification_write_plan( + &plan, + |item_key| Ok::<_, ()>(preflight_state(&plan, item_key)), + |request| { + let mut state = applied_state(request); + state.tags.push(tag(" ", None)); + Ok::<_, ()>(state) + }, + ); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + + let receipt = execute_classification_write_plan( + &plan, + |item_key| Ok::<_, ()>(preflight_state(&plan, item_key)), + |request| Ok::<_, ()>(applied_state(request)), + ); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::Applied); + assert_eq!(receipt.applied_item_keys, ["A", "B"]); + assert_eq!(receipt.rollback_operations[0].item_key, "B"); + assert_eq!(receipt.rollback_operations[1].item_key, "A"); +} + fn classification_report(version: &str) -> conceptweave_zotero::ClassificationReport { classify_snapshot( version.into(), @@ -171,18 +487,18 @@ fn dry_run_is_default_and_preserves_exact_rollback_state() { }) .expect("reviewed dry-run changes must produce a plan"); - assert_eq!(plan.mode, WriteMode::DryRun); - assert_eq!(plan.operations[0].item_key, "A"); - assert_eq!(plan.operations[1].item_key, "B"); + assert_eq!(plan.mode(), WriteMode::DryRun); + assert_eq!(plan.operations()[0].item_key, "A"); + assert_eq!(plan.operations()[1].item_key, "B"); assert_eq!( - plan.operations[1].rollback_collection_keys, - plan.operations[1].before_collection_keys + plan.operations()[1].rollback_collection_keys, + plan.operations()[1].before_collection_keys ); assert_eq!( - plan.operations[1].rollback_tags, + plan.operations()[1].rollback_tags, vec![tag("Imported", Some(1))] ); - assert!(plan.source_records_preserved); + assert!(plan.source_records_preserved()); } #[test] @@ -488,7 +804,7 @@ fn write_plan_fails_closed_for_untrusted_stale_or_unsafe_changes() { let manual_plan = build_classification_write_plan(&version_ten, &manual_marker, WriteMode::DryRun, |_| true) .unwrap(); - assert_eq!(manual_plan.operations[1].after_tags[0].tag_type, None); + assert_eq!(manual_plan.operations()[1].after_tags[0].tag_type, None); manual_marker.changes[0].after_tags[0].tag_type = Some(2); assert_eq!( diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs new file mode 100644 index 00000000..7b9338c8 --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -0,0 +1,273 @@ +use conceptweave_zotero::{ + ClassificationItemState, ClassificationWriteOutcome, Disposition, ItemData, ItemTag, + ReviewedClassificationChange, ReviewedClassificationWriteSet, WriteMode, ZoteroItem, + build_classification_write_plan, classify_snapshot, execute_classification_write_plan, +}; + +fn tag(name: &str, tag_type: Option) -> ItemTag { + ItemTag { + tag: name.into(), + tag_type, + } +} + +fn classification_report() -> conceptweave_zotero::ClassificationReport { + classify_snapshot( + "10.0.0".into(), + Some("server-1".into()), + 42, + 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_collection".into()], + tags: vec![tag("Imported", Some(1))], + }, + }, + ZoteroItem { + source_record: None, + key: "A".into(), + version: 7, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }, + ], + ) +} + +fn reviewed(report: &conceptweave_zotero::ClassificationReport) -> ReviewedClassificationWriteSet { + 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: "B".into(), + item_version: 9, + reviewed_disposition: Disposition::EvaluationGovernance, + before_collection_keys: vec!["source_collection".into()], + after_collection_keys: vec!["evaluation_collection".into()], + before_tags: vec![tag("Imported", Some(1))], + after_tags: vec![tag("Evaluation", None), tag("Imported", Some(1))], + }, + ReviewedClassificationChange { + item_key: "A".into(), + item_version: 7, + reviewed_disposition: Disposition::Generation, + before_collection_keys: vec![], + after_collection_keys: vec!["generation_collection".into()], + before_tags: vec![], + after_tags: vec![tag("Generation", None)], + }, + ], + } +} + +fn preflight_state( + plan: &conceptweave_zotero::ClassificationWritePlan, + item_key: &str, +) -> ClassificationItemState { + let operation = plan + .operations() + .iter() + .find(|operation| operation.item_key == item_key) + .unwrap(); + ClassificationItemState { + server_id: "server-1".into(), + library_version: plan.library_version(), + item_key: item_key.into(), + item_version: operation.item_version, + collection_keys: operation.before_collection_keys.clone(), + tags: operation.before_tags.clone(), + } +} + +fn assert_receipt_binding( + receipt: &conceptweave_zotero::ClassificationWriteReceipt, + report: &conceptweave_zotero::ClassificationReport, +) { + assert_eq!(receipt.review_id, "review-1"); + assert_eq!(receipt.authority_receipt, "authority-1"); + assert_eq!(receipt.server_id.as_deref(), Some("server-1")); + assert_eq!(receipt.zotero_version, report.zotero_version); + assert_eq!(receipt.library_version, 42); + assert_eq!(receipt.rule_revision, report.rule_revision); + assert_eq!(receipt.snapshot_digest, report.snapshot_digest); + if receipt.indeterminate_item_key.is_none() { + assert!(receipt.indeterminate_request.is_none()); + assert!(receipt.reconciliation_observation.is_none()); + } + assert_eq!( + serde_json::to_value(receipt).unwrap()["proposal_digest"], + conceptweave_zotero::classification_proposal_digest(report) + ); +} + +#[test] +fn every_receipt_binds_to_the_reviewed_plan_coordinates() { + let report = classification_report(); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::DryRun, |_| true) + .unwrap(); + let receipt = execute_classification_write_plan( + &plan, + |_| -> Result { panic!("dry-run must not preflight") }, + |_| -> Result { panic!("dry-run must not write") }, + ); + + assert_receipt_binding(&receipt, &report); +} + +#[test] +fn applied_and_preflight_failure_receipts_bind_to_the_reviewed_plan() { + let report = classification_report(); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let preflight_failure = execute_classification_write_plan( + &plan, + |_| Err::(()), + |_| -> Result { panic!("preflight failure must not write") }, + ); + assert_eq!( + preflight_failure.outcome, + ClassificationWriteOutcome::PreflightFailure + ); + assert_receipt_binding(&preflight_failure, &report); + + let applied = execute_classification_write_plan( + &plan, + |item_key| Ok::<_, ()>(preflight_state(&plan, item_key)), + |request| { + Ok::<_, ()>(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(), + }) + }, + ); + assert_eq!(applied.outcome, ClassificationWriteOutcome::Applied); + assert_receipt_binding(&applied, &report); +} + +#[test] +fn dry_run_receipt_enumerates_every_operation_as_not_attempted() { + let report = classification_report(); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::DryRun, |_| true) + .unwrap(); + let receipt = execute_classification_write_plan( + &plan, + |_| -> Result { panic!("dry-run must not preflight") }, + |_| -> Result { panic!("dry-run must not write") }, + ); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::DryRun); + assert_eq!(receipt.not_attempted_item_keys, ["A", "B"]); +} + +#[test] +fn unexpected_observation_cannot_authorize_inverse_rollback() { + let report = classification_report(); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); + let initial_preflight_count = plan.operations().len(); + let mut reads = 0usize; + + let receipt = execute_classification_write_plan( + &plan, + |item_key| { + reads += 1; + if reads <= initial_preflight_count { + return Ok::<_, ()>(preflight_state(&plan, item_key)); + } + + let operation = &plan.operations()[0]; + Ok::<_, ()>(ClassificationItemState { + server_id: "server-1".into(), + library_version: 43, + item_key: operation.item_key.clone(), + item_version: operation.item_version + 1, + collection_keys: vec!["unexpected_collection".into()], + tags: operation.before_tags.clone(), + }) + }, + |_| Err::(()), + ); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_receipt_binding(&receipt, &report); + assert_eq!(receipt.failed_item_key.as_deref(), Some("A")); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("A")); + assert!(receipt.rollback_operations.is_empty()); + assert!(receipt.applied_item_keys.is_empty()); + let audit = serde_json::to_value(&receipt).unwrap(); + assert_eq!(audit["indeterminate_request"]["item_key"], "A"); + assert_eq!( + audit["reconciliation_observation"]["collection_keys"][0], + "unexpected_collection" + ); +} + +#[test] +fn unexpected_mutation_requires_the_planned_server_and_item_identity() { + for (server_id, item_key) in [("other-server", "A"), ("server-1", "other-item")] { + let report = classification_report(); + let plan = build_classification_write_plan( + &report, + &reviewed(&report), + WriteMode::Execute, + |_| true, + ) + .unwrap(); + let initial_preflight_count = plan.operations().len(); + let mut reads = 0usize; + + let receipt = execute_classification_write_plan( + &plan, + |requested_item_key| { + reads += 1; + if reads <= initial_preflight_count { + return Ok::<_, ()>(preflight_state(&plan, requested_item_key)); + } + + Ok::<_, ()>(ClassificationItemState { + server_id: server_id.into(), + library_version: 43, + item_key: item_key.into(), + item_version: 8, + collection_keys: vec!["unexpected_collection".into()], + tags: vec![], + }) + }, + |_| Err::(()), + ); + + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("A")); + assert!(receipt.rollback_operations.is_empty()); + } +} diff --git a/docs/CONTEXT_MAP.md b/docs/CONTEXT_MAP.md index 073da7a1..21bab49a 100644 --- a/docs/CONTEXT_MAP.md +++ b/docs/CONTEXT_MAP.md @@ -15,7 +15,7 @@ and binds proposals, projected unclassified metadata and pending keys before Governance verifies the entire reviewed change set. Duplicate membership and full-text decisions retain their separate contracts; no new owner is introduced. -- Zotero Local API -> research evidence intake: **Anti-Corruption Layer into Semantic Discovery**. Zotero remains the bibliographic system of record; ConceptWeave consumes a version-pinned, read-only Local API snapshot and emits proposal evidence only. Item metadata, attachments, collection/tag truth, and future write authority remain in Zotero. No Zotero record becomes semantic authority without ConceptWeave validation/review/publication. +- Zotero Local API -> research evidence intake: **Anti-Corruption Layer into Semantic Discovery**. Zotero remains the bibliographic system of record; ConceptWeave consumes a version-pinned snapshot and emits proposal evidence. Execute-mode metadata changes cross only a caller-owned authenticated adapter after complete preflight; ConceptWeave retains no API key and records verified item-level outcomes and rollback coordinates. Item metadata, attachments, collection/tag truth, and write authority remain in Zotero. No Zotero record becomes semantic authority without ConceptWeave validation/review/publication. - contextual-orchestrator -> Semantic Discovery: **Anti-Corruption Layer**. Model/provider envelopes never enter the domain model directly. - LineageWeave -> Source Observation: **Anti-Corruption Layer**. Inferred/proposed lineage remains explicitly non-authoritative until ConceptWeave governance evaluates it. - context-graph-contracts <-> Interoperability: **Shared Kernel only for versioned public contracts**, kept minimal. diff --git a/docs/PRD.md b/docs/PRD.md index 6fd2466a..b7f04907 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -40,6 +40,11 @@ Validate syntax, identifiers, relationship cardinality, mapping completeness, du ### FR-5 Governed review +When a write response is lost, show the affected item as unresolved even if a +later read looks unchanged or matches the requested result. Preserve what was +submitted and what was later observed, keep confirmed earlier results, and do not +automatically repeat or undo the uncertain change. + Research changes must use the evidence actually reviewed. Changed supporting metadata or incomplete source inventories must stop a change plan before approval is consumed. Older reviews lacking this evidence binding require fresh independent @@ -71,7 +76,9 @@ Read a complete Zotero Local API observation with one consistent library version For every connected duplicate component, accept externally verified steward decisions selecting one component-level canonical item. Produce a local-only manifest that binds decisions to the raw snapshot, complete item revisions, exact duplicate membership, current proposals and retained source metadata. Reject missing or inconsistent source inventory and invalid decisions before requesting approval. Changed retained evidence requires fresh independent approval, even when duplicate members are unchanged. Record every component source revision plus before, after, and rollback canonical-key mappings. Classification preserves every Zotero source record. -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. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. +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 bound to the exact reviewed plan coordinates. Dry-run receipts enumerate every planned item as untouched. Execution receipts identify verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions, including an identity- and version-confirmed unexpected mutation. Cross-item atomicity is not claimed. 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 4272c8e4..b4f14615 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -59,6 +59,14 @@ Evaluation must separate extraction recall, semantic correctness, structural cor ## 11. Zotero research intake +Execution receipts retain the verified proposal/source binding in every outcome. +On a failed or invalid write response, `indeterminate_request` preserves the exact +submitted server/library/item preconditions and complete replacement arrays; +`reconciliation_observation` retains any subsequent read without attributing it +to that request. Matching before/after state never clears uncertainty or grants an +inverse. Only earlier directly verified writes remain applied/reversible. These +serialized fields are audit evidence, not inputs authorizing execution or retry. + Classification write planning also calls the shared report validator before external authority. Its required `proposal_digest` binds the reviewed v2 proposal, unclassified-metadata and pending-key payloads and is retained in the plan. Legacy @@ -93,4 +101,4 @@ Structural, source, proposal, and label checks precede the external verifier. Bl Provider deserialization captures each complete JSON object before projecting metadata. Snapshot hashing serializes the domain marker `conceptweave-zotero-snapshot-v2` followed by key-ordered pairs of that canonical source JSON and the actual typed classifier input. Unknown nested fields, array order, and omitted-versus-explicit default fields remain bound; changing a typed input after decoding also changes the digest. Synthetic offline typed items have no captured provider object and bind an explicit absent-source value alongside their typed input. Earlier reduced-content digests remain historical evidence and cannot establish this complete-content contract; regenerate the report and review artifacts and obtain fresh approval before any release or approved write. 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. Zotero 9 execute mode fails closed and no mutation transport exists in this slice. A future Zotero 10+ writer must keep its Local API key outside serializable structures, revalidate server/library/item state before writing, and return item-level success, partial-failure, and rollback receipts. +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. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. diff --git a/docs/UBIQUITOUS_LANGUAGE.md b/docs/UBIQUITOUS_LANGUAGE.md index 085e2cd4..1c36c76e 100644 --- a/docs/UBIQUITOUS_LANGUAGE.md +++ b/docs/UBIQUITOUS_LANGUAGE.md @@ -18,6 +18,9 @@ | Semantic Steward | Authorized reviewer responsible for accepting or rejecting semantic meaning. | | Reviewed Classification Change | Authorized complete replacement of one paper's collection and tag state, bound to its observed revision. | | Classification Write Plan | Local deterministic artifact retaining the verified proposal/source-scope binding, exact preconditions and before/after/rollback metadata; dry-run by default, never proof of execution. | +| Classification Write Receipt | Secret-free, reviewed-plan-bound result that distinguishes dry-run, verified completion, preflight failure, and partial failure while retaining applied, failed, untouched, and safely reversible coordinates. | +| Classification Rollback Operation | Reverse-ordered complete-state restoration bound to the post-write item revision returned by the Local API. | +| Indeterminate Request | Exact submitted write whose completion cannot be proven; its optional later observation does not grant completion, retry or inverse authority. | | Reviewed Duplicate Merge Set | Independently verified decisions selecting one canonical item per connected duplicate component, bound to exact source revisions, candidate membership, proposals and retained source scope. | | Authority Receipt | Opaque proof checked by the Governance & Publication boundary; it contains no reviewer identity or credential. | | Canonical-Key Operation | Reversible local mapping from every source in a connected duplicate component to one retained key, with complete reviewed revisions and the exact rollback mapping. | diff --git a/docs/UML.md b/docs/UML.md index 323f7341..1af557e7 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -70,5 +70,15 @@ sequenceDiagram Intake->>Steward: independently verify complete reviewed set Steward-->>Intake: authority result, not a replacement digest Intake->>Report: dry-run write plan with exact rollback state - Intake-->>Zotero: Zotero 9 execute rejected; no mutation transport + Intake-->>Zotero: Zotero 9 execute rejected + opt caller supplies authenticated Zotero 10+ adapter + Intake->>Zotero: preflight every planned item + Zotero-->>Intake: exact server/library/item state + loop stop on first failure + Intake->>Zotero: conditional complete metadata replacement + Zotero-->>Intake: post-write revision and complete state + end + Intake->>Report: applied/failed/untouched receipt + reverse rollback operations + Note over Intake,Report: Failed response stays unknown; retain exact request and observation, no inferred inverse + end ``` diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index d55337bc..082e60ba 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,9 +14,9 @@ Issue #8 requires classification changes to default to dry-run, preserve complet ## Decision -ConceptWeave builds a local-only `ClassificationWritePlan` from an externally verified complete review set. Dry-run is the default. The review must match the exact Zotero version, server identity, library version, classifier revision, raw-snapshot digest, complete item-key/item-version coordinates, and observed collection/tag state. The plan retains the reviewed Zotero version used for execute eligibility. It rejects unknown or duplicate items, detached item revisions, blank or duplicate metadata, unsupported tag types, no-op changes, and `NeedsStewardReview` as a write decision. Operations are deterministic and retain complete before, after, and rollback states. Manual tag markers `None` and `0` are canonicalized to `None`; automatic tag type `1` is preserved. +ConceptWeave builds a local-only `ClassificationWritePlan` from an externally verified complete review set. Dry-run is the default. The review must match the exact Zotero version, server identity, library version, classifier revision, raw-snapshot digest, complete item-key/item-version coordinates, and observed collection/tag state. The plan retains the reviewed Zotero version used for execute eligibility, while private fields and read-only accessors prevent external callers from mutating validated execution state. Every receipt copies the plan's review and snapshot coordinates so an outcome cannot be detached from its authority or evidence. It rejects unknown or duplicate items, detached item revisions, blank or duplicate metadata, unsupported tag types, no-op changes, and `NeedsStewardReview` as a write decision. Operations are deterministic and retain complete before, after, and rollback states. Manual tag markers `None` and `0` are canonicalized to `None`; automatic tag type `1` is preserved. -Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. A later adapter must preflight all operations and return item-level partial-success and rollback receipts; it must not claim cross-item transactionality or delete source records and attachments. +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. ## Consequences @@ -48,10 +48,31 @@ 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 advances, while AC6 remains incomplete until an authenticated Zotero 10+ adapter and approved live write/rollback are verified. +- AC5 is implemented and AC6 now has deterministic preflight, partial-failure, and rollback-receipt semantics. AC6 remains incomplete until an authenticated Zotero 10+ adapter and approved live write/rollback are verified. ## Alternatives considered +### Execution evidence correction (2026-09-06, Proposed) + +In the context of uncertain write responses, facing concurrent edits and delayed +requests that can produce indistinguishable observations, we decided for preserving +uncertainty and the exact submitted request, and against inferring completion or +conditional rollback from post-read metadata, to prevent overwriting an unrelated +edit or repeating a still-running request, accepting that recovery must wait for +independent causal evidence instead of automatic retry. + +Independent source review found the same unsafe inference in the later executor; +fixing only full-text wrappers cannot restore uncertainty already discarded by +this owner. RED `646a10c` retained existing lost-response/unchanged-state scenarios +but corrected their unsafe assertions. Runtime `c09d101` removes those inference +branches; `e169630` checks the complete submitted request and absent observations. +The observed metadata remains available for investigation, but carries no retry +or rollback authority. A serialized mutable receipt is audit data and cannot +construct the private executable plan. All outcome receipts retain the verified +proposal/source binding (`b91ad9f`, RED `e8b4c06`). Later recovery consumers must +preserve these fields and refuse an unknown original write, including when the +list of previously verified inverse operations is empty. + - Writing through Zotero 9 was rejected because the provider does not support it. - Storing only collection/tag deltas was rejected because Zotero array updates are complete replacements and cannot prove lossless rollback. - Adding the HTTP writer now was rejected because no Zotero 10+ runtime or approved local key is available for end-to-end verification. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c221816f..01db27c8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,36 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #15 execution-scope and uncertainty repair (2026-09-06) + +Baseline `45e9c493` passed 87 tests/19 result suites; normal merge `2887f70` +preserves that executor and parent `eb8eaa4`, passing 109 tests/19 suites. +RED `e8b4c06` failed three receipt-binding tests, repaired by `b91ad9f` retaining +the proposal/source digest across dry-run, applied, preflight and partial failure. + +Independent review then found a root causal-completion defect: matching before +metadata could hide a delayed request, while matching after or unrelated newer +metadata could invent applied/rollback status. Existing scenarios were preserved +with corrected expectations in RED `646a10c` (two failures). Runtime `c09d101` +removes those inferences and retains the exact submitted request plus optional +observation. Only earlier directly verified operations retain applied/inverse +status. Test `e169630` compares the complete captured request and missing fields. +Independent static review found no remaining actionable owner defect; it is not +GitHub approval. Original and later executor paths both require propagation. + +Final source: 109 passing tests/19 result suites including three doctests, strict +all-target Clippy, warnings-denied rustdoc and unchanged coverage gate passed. +Coverage: 185/185 functions, 1770/1770 source-normalized regions, 320/320 normalized +branches. Raw LLVM is 1998/2050 lines, 2934/3014 regions and 280/320 branches, not +100%. Evidence logs: `/tmp/conceptweave-pr15-causal-final.log`, `-clippy-final.log`, +`-rustdoc.log`, and `-coverage.log` share the `conceptweave-pr15-causal` prefix. + +No real library write, paper decision, approval or release occurred. Subsequent +execution/recovery/full-text consumers must inherit unknown-request semantics, +retain exact request and earlier receipt fields, and reject retry/rollback of an +unknown original write even when its inverse list is empty. Protected integration, +descendant adoption, live write/rollback and actual reclassification remain open. + ### PR #13 source-scope integration checkpoint (2026-09-06) Normal merge `5df57a7` preserves write-plan head `b41217b` and source-scope