From b605961bad6ca65834e86a73da0cccbb5b1bc110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:50:20 +0900 Subject: [PATCH 01/26] test(zotero): require reversible write receipts --- .../tests/classification_write_plan.rs | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index a526bd8b..e92c5ab5 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -1,6 +1,8 @@ use conceptweave_zotero::{ - Disposition, ItemData, ItemTag, ReviewedClassificationChange, ReviewedClassificationWriteSet, - WriteMode, WritePlanError, ZoteroItem, build_classification_write_plan, classify_snapshot, + ClassificationItemState, ClassificationWriteOutcome, Disposition, ItemData, ItemTag, + ReviewedClassificationChange, ReviewedClassificationWriteSet, WriteMode, WritePlanError, + ZoteroItem, build_classification_write_plan, classify_snapshot, + execute_classification_write_plan, }; fn tag(name: &str, tag_type: Option) -> ItemTag { @@ -10,6 +12,86 @@ 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: 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"]); + assert_eq!(written, ["A", "B"]); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.failed_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!(receipt.rollback_operations.is_empty()); +} + fn classification_report(version: &str) -> conceptweave_zotero::ClassificationReport { classify_snapshot( version.into(), From 50320a59d2f370156401973acd0cb1cfaeedca83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:51:46 +0900 Subject: [PATCH 02/26] feat(zotero): emit reversible write receipts --- crates/conceptweave-zotero/src/lib.rs | 221 ++++++++++++++++++ .../tests/classification_write_plan.rs | 25 +- 2 files changed, 231 insertions(+), 15 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index b7f4b359..bc45edb9 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -346,6 +346,82 @@ pub struct ClassificationWritePlan { pub source_records_preserved: bool, } +/// 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 { + /// 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, + /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WritePlanError { @@ -969,6 +1045,151 @@ 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 { + outcome: ClassificationWriteOutcome::DryRun, + applied_item_keys: Vec::new(), + failed_item_key: None, + not_attempted_item_keys: Vec::new(), + rollback_operations: Vec::new(), + }; + } + let Some(server_id) = plan + .server_id + .as_deref() + .filter(|server_id| !server_id.trim().is_empty()) + else { + return preflight_failure_receipt(plan, None); + }; + + for operation in &plan.operations { + let Ok(state) = preflight(&operation.item_key) else { + return preflight_failure_receipt(plan, Some(&operation.item_key)); + }; + let Ok((collections, tags)) = normalized_metadata(&state.collection_keys, &state.tags) + else { + return preflight_failure_receipt(plan, Some(&operation.item_key)); + }; + if state.server_id != server_id + || state.library_version != plan.library_version + || state.item_key != operation.item_key + || state.item_version != operation.item_version + || collections != operation.before_collection_keys + || tags != operation.before_tags + { + 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 valid_response = response.as_ref().ok().and_then(|state| { + normalized_metadata(&state.collection_keys, &state.tags) + .ok() + .map(|metadata| (state, metadata)) + }); + let Some((state, (collections, tags))) = valid_response else { + rollback_operations.reverse(); + return partial_failure_receipt( + plan, + operation_index, + applied_item_keys, + rollback_operations, + ); + }; + if state.server_id != server_id + || state.library_version <= current_library_version + || state.item_key != operation.item_key + || state.item_version <= operation.item_version + || collections != operation.after_collection_keys + || tags != operation.after_tags + { + rollback_operations.reverse(); + return partial_failure_receipt( + plan, + operation_index, + applied_item_keys, + rollback_operations, + ); + } + 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 { + outcome: ClassificationWriteOutcome::Applied, + applied_item_keys, + failed_item_key: None, + not_attempted_item_keys: Vec::new(), + rollback_operations, + } +} + +fn preflight_failure_receipt( + plan: &ClassificationWritePlan, + failed_item_key: Option<&str>, +) -> ClassificationWriteReceipt { + ClassificationWriteReceipt { + outcome: ClassificationWriteOutcome::PreflightFailure, + applied_item_keys: Vec::new(), + failed_item_key: failed_item_key.map(str::to_owned), + 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, +) -> ClassificationWriteReceipt { + ClassificationWriteReceipt { + outcome: ClassificationWriteOutcome::PartialFailure, + applied_item_keys, + failed_item_key: Some(plan.operations[failed_index].item_key.clone()), + 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 e92c5ab5..f40c43fb 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -15,13 +15,9 @@ 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 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( @@ -67,20 +63,19 @@ fn execution_preflights_every_item_and_returns_reversible_partial_failure() { 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_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 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") }, From 4594eafa5d65999a7c4b93240451b2e830dcebcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:52:55 +0900 Subject: [PATCH 03/26] test(zotero): cover write boundary drift --- .../tests/classification_write_plan.rs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index f40c43fb..f9edc9e0 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -87,6 +87,157 @@ fn dry_run_execution_never_calls_the_write_boundary() { assert!(receipt.rollback_operations.is_empty()); } +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); + + let mut missing_server_plan = plan.clone(); + missing_server_plan.server_id = Some(" ".into()); + let receipt = execute_classification_write_plan( + &missing_server_plan, + |_| -> Result { panic!("missing server must fail first") }, + |_| -> Result { panic!("missing server must fail first") }, + ); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PreflightFailure); + assert_eq!(receipt.failed_item_key, None); +} + +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(), From 0783f8f84b97c13b7c0dbbcd4209ec7dc7d6d1b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:53:07 +0900 Subject: [PATCH 04/26] style(zotero): format write boundary tests --- .../tests/classification_write_plan.rs | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index f9edc9e0..412fd979 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -109,13 +109,9 @@ fn preflight_state( #[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 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, @@ -136,7 +132,10 @@ fn execution_fails_closed_for_each_preflight_mismatch() { }, |_| -> Result { panic!("preflight must finish first") }, ); - assert_eq!(receipt.outcome, ClassificationWriteOutcome::PreflightFailure); + 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"]); } @@ -150,14 +149,22 @@ fn execution_fails_closed_for_each_preflight_mismatch() { }, |_| -> Result { panic!("invalid metadata must fail first") }, ); - assert_eq!(receipt.outcome, ClassificationWriteOutcome::PreflightFailure); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure + ); let receipt = execute_classification_write_plan( &plan, |_| Err::(()), - |_| -> Result { panic!("failed preflight must prevent writes") }, + |_| -> Result { + panic!("failed preflight must prevent writes") + }, + ); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure ); - assert_eq!(receipt.outcome, ClassificationWriteOutcome::PreflightFailure); let mut missing_server_plan = plan.clone(); missing_server_plan.server_id = Some(" ".into()); @@ -166,7 +173,10 @@ fn execution_fails_closed_for_each_preflight_mismatch() { |_| -> Result { panic!("missing server must fail first") }, |_| -> Result { panic!("missing server must fail first") }, ); - assert_eq!(receipt.outcome, ClassificationWriteOutcome::PreflightFailure); + assert_eq!( + receipt.outcome, + ClassificationWriteOutcome::PreflightFailure + ); assert_eq!(receipt.failed_item_key, None); } @@ -186,13 +196,9 @@ fn applied_state( #[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 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, From d833ec0ac945ace69f65c352399f2d10d518df06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:54:17 +0900 Subject: [PATCH 05/26] docs(zotero): trace write execution receipts --- docs/PRD.md | 2 ++ docs/TRD.md | 2 +- docs/UBIQUITOUS_LANGUAGE.md | 2 ++ docs/UML.md | 11 ++++++++++- docs/adr/0007-reviewed-zotero-write-plan.md | 4 ++-- docs/product-technical-gap-baseline.md | 2 +- 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 2e663ae1..dc5bde3a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,6 +62,8 @@ For every detected duplicate cluster, accept exactly one externally verified ste 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. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, untouched items, and reverse-ordered rollback operations bound to post-write item revisions. Cross-item atomicity is not claimed. + Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index 3792cd19..a5879e77 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,4 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types. 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. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified responses, stops on the first adapter or response failure, and returns applied, failed, untouched, and reverse-ordered rollback 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 1ef4d769..220b596d 100644 --- a/docs/UBIQUITOUS_LANGUAGE.md +++ b/docs/UBIQUITOUS_LANGUAGE.md @@ -18,6 +18,8 @@ | 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 dry-run artifact containing exact preconditions and before/after/rollback metadata; not proof of execution. | +| Classification Write Receipt | Secret-free result that distinguishes dry-run, verified completion, preflight failure, and partial failure while retaining applied, failed, untouched, and rollback coordinates. | +| Classification Rollback Operation | Reverse-ordered complete-state restoration bound to the post-write item revision returned by the Local API. | | Reviewed Duplicate Merge Set | Complete steward decisions selecting one consistent canonical item across every overlapping duplicate group in a snapshot. | | Authority Receipt | Opaque proof checked by the Governance & Publication boundary; it contains no reviewer identity or credential. | | Canonical-Key Operation | Reversible local mapping from each duplicate source key to one retained key, with the exact rollback mapping. | diff --git a/docs/UML.md b/docs/UML.md index 0c843a6c..c055bb4b 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -60,5 +60,14 @@ sequenceDiagram Report-->>Steward: reversible local mapping; source records preserved Steward->>Intake: verified collection/tag changes 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 + end ``` diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 93c50013..31450109 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -12,13 +12,13 @@ Issue #8 requires classification changes to default to dry-run, preserve complet ConceptWeave builds a local-only `ClassificationWritePlan` from an externally verified complete review set. Dry-run is the default. The plan must match the exact server identity, library version, classifier revision, raw-snapshot digest, item version, and complete observed collection/tag state. It rejects unknown or duplicate items, blank or duplicate metadata, no-op changes, and `NeedsStewardReview` as a write decision. Operations are deterministic and retain complete before, after, and rollback states. Zotero tag `type` 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. 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. It stops at the first failure and emits a secret-free receipt with applied, failed, untouched, and reverse-ordered rollback operations. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences - Review and rollback semantics can be tested on Zotero 9 without changing the library. - Exact before-state checks prevent silent loss of unrelated collections or automatic-tag metadata. -- AC5 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c6c3d4b2..8c178101 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,7 +44,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Duplicate clusters now produce a snapshot-bound local review manifest only after external steward verification; exact source revisions and before/after/rollback canonical mappings are retained while Zotero records remain unchanged. Reviewed collection/tag changes can now produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; it preserves automatic-tag type and rejects Zotero 9 execute mode. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires a Zotero 10+ transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Duplicate clusters now produce a snapshot-bound local review manifest only after external steward verification; exact source revisions and before/after/rollback canonical mappings are retained while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; automatic-tag type is preserved and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, verifies post-write state, and emits reverse-ordered rollback operations using returned item revisions. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 9f1913cbd3236259d0f450b7373c6986970a0252 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:02:40 +0900 Subject: [PATCH 06/26] docs(zotero): map authenticated write boundary --- docs/CONTEXT_MAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CONTEXT_MAP.md b/docs/CONTEXT_MAP.md index 5da32fef..7057f6f2 100644 --- a/docs/CONTEXT_MAP.md +++ b/docs/CONTEXT_MAP.md @@ -10,7 +10,7 @@ ## External relationships -- 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. From f454c5555fe2c8f332053cc3563ba40cfb718654 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:16:06 +0900 Subject: [PATCH 07/26] fix(zotero): reconcile indeterminate writes --- crates/conceptweave-zotero/src/lib.rs | 68 ++++++++++++----- .../tests/classification_write_plan.rs | 74 ++++++++++++++++++- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 6 files changed, 128 insertions(+), 22 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index a8bf94e0..0ff97624 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -426,6 +426,8 @@ pub struct ClassificationWriteReceipt { 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, /// Items whose write was not attempted. pub not_attempted_item_keys: Vec, /// Verified inverse operations in safe reverse application order. @@ -1100,6 +1102,7 @@ pub fn execute_classification_write_plan( outcome: ClassificationWriteOutcome::DryRun, applied_item_keys: Vec::new(), failed_item_key: None, + indeterminate_item_key: None, not_attempted_item_keys: Vec::new(), rollback_operations: Vec::new(), }; @@ -1144,35 +1147,62 @@ pub fn execute_classification_write_plan( tags: operation.after_tags.clone(), }; let response = write_item(&request); - let valid_response = response.as_ref().ok().and_then(|state| { + let valid_response = response.ok().and_then(|state| { normalized_metadata(&state.collection_keys, &state.tags) .ok() .map(|metadata| (state, metadata)) }); - let Some((state, (collections, tags))) = valid_response else { + let verified_state = valid_response.and_then(|(state, (collections, tags))| { + (state.server_id == server_id + && state.library_version > current_library_version + && state.item_key == operation.item_key + && state.item_version > operation.item_version + && collections == operation.after_collection_keys + && tags == operation.after_tags) + .then_some(state) + }); + let Some(state) = verified_state else { + let reconciled_state = preflight(&operation.item_key).ok(); + let reconciled_after = reconciled_state.as_ref().is_some_and(|state| { + normalized_metadata(&state.collection_keys, &state.tags) + .is_ok_and(|(collections, tags)| { + state.server_id == server_id + && state.library_version > current_library_version + && state.item_key == operation.item_key + && state.item_version > operation.item_version + && collections == operation.after_collection_keys + && tags == operation.after_tags + }) + }); + let reconciled_before = reconciled_state.as_ref().is_some_and(|state| { + normalized_metadata(&state.collection_keys, &state.tags) + .is_ok_and(|(collections, tags)| { + state.server_id == server_id + && state.library_version == current_library_version + && state.item_key == operation.item_key + && state.item_version == operation.item_version + && collections == operation.before_collection_keys + && tags == operation.before_tags + }) + }); + if let Some(state) = reconciled_state.filter(|_| reconciled_after) { + 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(); return partial_failure_receipt( plan, operation_index, applied_item_keys, rollback_operations, + (!reconciled_after && !reconciled_before).then_some(operation.item_key.as_str()), ); }; - if state.server_id != server_id - || state.library_version <= current_library_version - || state.item_key != operation.item_key - || state.item_version <= operation.item_version - || collections != operation.after_collection_keys - || tags != operation.after_tags - { - rollback_operations.reverse(); - return partial_failure_receipt( - plan, - operation_index, - applied_item_keys, - rollback_operations, - ); - } current_library_version = state.library_version; applied_item_keys.push(operation.item_key.clone()); rollback_operations.push(ClassificationRollbackOperation { @@ -1187,6 +1217,7 @@ pub fn execute_classification_write_plan( outcome: ClassificationWriteOutcome::Applied, applied_item_keys, failed_item_key: None, + indeterminate_item_key: None, not_attempted_item_keys: Vec::new(), rollback_operations, } @@ -1200,6 +1231,7 @@ fn preflight_failure_receipt( outcome: ClassificationWriteOutcome::PreflightFailure, applied_item_keys: Vec::new(), failed_item_key: failed_item_key.map(str::to_owned), + indeterminate_item_key: None, not_attempted_item_keys: plan .operations .iter() @@ -1214,11 +1246,13 @@ fn partial_failure_receipt( failed_index: usize, applied_item_keys: Vec, rollback_operations: Vec, + indeterminate_item_key: Option<&str>, ) -> ClassificationWriteReceipt { ClassificationWriteReceipt { outcome: ClassificationWriteOutcome::PartialFailure, applied_item_keys, failed_item_key: Some(plan.operations[failed_index].item_key.clone()), + indeterminate_item_key: indeterminate_item_key.map(str::to_owned), not_attempted_item_keys: plan .operations .iter() diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 450ffb25..d28c689c 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -54,10 +54,11 @@ fn execution_preflights_every_item_and_returns_reversible_partial_failure() { }, ); - assert_eq!(preflighted, ["A", "B"]); + 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, None); assert!(receipt.not_attempted_item_keys.is_empty()); assert_eq!(receipt.applied_item_keys, ["A"]); assert_eq!(receipt.rollback_operations.len(), 1); @@ -84,9 +85,80 @@ fn dry_run_execution_never_calls_the_write_boundary() { 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 execution_reconciles_a_committed_write_after_its_response_is_lost() { + 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 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| { + 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, None); + assert_eq!(receipt.applied_item_keys, ["A", "B"]); + assert_eq!(receipt.rollback_operations[0].item_key, "B"); + assert_eq!(receipt.rollback_operations[0].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!(receipt.rollback_operations.is_empty()); + assert_eq!(receipt.not_attempted_item_keys, ["B"]); +} + fn preflight_state( plan: &conceptweave_zotero::ClassificationWritePlan, item_key: &str, diff --git a/docs/PRD.md b/docs/PRD.md index 9f23b332..03f9363d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,7 +62,7 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. 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, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, untouched items, and reverse-ordered rollback operations bound to post-write item revisions. Cross-item atomicity is not claimed. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions. Cross-item atomicity is not claimed. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index eb671157..a711e29b 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,4 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified responses, stops on the first adapter or response failure, and returns applied, failed, untouched, and reverse-ordered rollback coordinates. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +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. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The 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/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 6728cba3..c22253d5 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -12,7 +12,7 @@ Issue #8 requires classification changes to default to dry-run, preserve complet 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. -Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. It stops at the first failure and emits a secret-free receipt with applied, failed, untouched, and reverse-ordered rollback operations. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7992f4a0..92aba434 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,7 +44,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; automatic-tag type is preserved and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, verifies post-write state, and emits reverse-ordered rollback operations using returned item revisions. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; automatic-tag type is preserved and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 4182ee104131b65056e7db92e68fce35090dc7d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:16:17 +0900 Subject: [PATCH 08/26] style(zotero): format write reconciliation --- crates/conceptweave-zotero/src/lib.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 0ff97624..afe5ca83 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1164,26 +1164,28 @@ pub fn execute_classification_write_plan( let Some(state) = verified_state else { let reconciled_state = preflight(&operation.item_key).ok(); let reconciled_after = reconciled_state.as_ref().is_some_and(|state| { - normalized_metadata(&state.collection_keys, &state.tags) - .is_ok_and(|(collections, tags)| { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and( + |(collections, tags)| { state.server_id == server_id && state.library_version > current_library_version && state.item_key == operation.item_key && state.item_version > operation.item_version && collections == operation.after_collection_keys && tags == operation.after_tags - }) + }, + ) }); let reconciled_before = reconciled_state.as_ref().is_some_and(|state| { - normalized_metadata(&state.collection_keys, &state.tags) - .is_ok_and(|(collections, tags)| { + normalized_metadata(&state.collection_keys, &state.tags).is_ok_and( + |(collections, tags)| { state.server_id == server_id && state.library_version == current_library_version && state.item_key == operation.item_key && state.item_version == operation.item_version && collections == operation.before_collection_keys && tags == operation.before_tags - }) + }, + ) }); if let Some(state) = reconciled_state.filter(|_| reconciled_after) { applied_item_keys.push(operation.item_key.clone()); From f67dc30efdb954bda4f77930bf442d0eaea49764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:16:34 +0900 Subject: [PATCH 09/26] test(zotero): model reconciled library revision --- .../conceptweave-zotero/tests/classification_write_plan.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index d28c689c..37c09ad4 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -31,7 +31,11 @@ fn execution_preflights_every_item_and_returns_reversible_partial_failure() { .unwrap(); Ok::<_, ()>(ClassificationItemState { server_id: "server-1".into(), - library_version: 42, + 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(), From 8beb776fd5c70b9f313477f653f4cb7439356d55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:17:24 +0900 Subject: [PATCH 10/26] refactor(zotero): share write state validation --- crates/conceptweave-zotero/src/lib.rs | 81 ++++++++++++--------------- 1 file changed, 37 insertions(+), 44 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index afe5ca83..6980c87e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1119,17 +1119,7 @@ pub fn execute_classification_write_plan( let Ok(state) = preflight(&operation.item_key) else { return preflight_failure_receipt(plan, Some(&operation.item_key)); }; - let Ok((collections, tags)) = normalized_metadata(&state.collection_keys, &state.tags) - else { - return preflight_failure_receipt(plan, Some(&operation.item_key)); - }; - if state.server_id != server_id - || state.library_version != plan.library_version - || state.item_key != operation.item_key - || state.item_version != operation.item_version - || collections != operation.before_collection_keys - || tags != operation.before_tags - { + if !matches_before_state(&state, server_id, plan.library_version, operation) { return preflight_failure_receipt(plan, Some(&operation.item_key)); } } @@ -1147,45 +1137,16 @@ pub fn execute_classification_write_plan( tags: operation.after_tags.clone(), }; let response = write_item(&request); - let valid_response = response.ok().and_then(|state| { - normalized_metadata(&state.collection_keys, &state.tags) - .ok() - .map(|metadata| (state, metadata)) - }); - let verified_state = valid_response.and_then(|(state, (collections, tags))| { - (state.server_id == server_id - && state.library_version > current_library_version - && state.item_key == operation.item_key - && state.item_version > operation.item_version - && collections == operation.after_collection_keys - && tags == operation.after_tags) - .then_some(state) + 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(); let reconciled_after = reconciled_state.as_ref().is_some_and(|state| { - normalized_metadata(&state.collection_keys, &state.tags).is_ok_and( - |(collections, tags)| { - state.server_id == server_id - && state.library_version > current_library_version - && state.item_key == operation.item_key - && state.item_version > operation.item_version - && collections == operation.after_collection_keys - && tags == operation.after_tags - }, - ) + matches_after_state(state, server_id, current_library_version, operation) }); let reconciled_before = reconciled_state.as_ref().is_some_and(|state| { - normalized_metadata(&state.collection_keys, &state.tags).is_ok_and( - |(collections, tags)| { - state.server_id == server_id - && state.library_version == current_library_version - && state.item_key == operation.item_key - && state.item_version == operation.item_version - && collections == operation.before_collection_keys - && tags == operation.before_tags - }, - ) + matches_before_state(state, server_id, current_library_version, operation) }); if let Some(state) = reconciled_state.filter(|_| reconciled_after) { applied_item_keys.push(operation.item_key.clone()); @@ -1225,6 +1186,38 @@ pub fn execute_classification_write_plan( } } +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>, From 2dfdf0c8f49601ab95958a216258099874dbbd22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:23:47 +0900 Subject: [PATCH 11/26] fix(zotero): seal validated write plans --- crates/conceptweave-zotero/src/lib.rs | 58 ++++++++++++++----- .../tests/classification_write_plan.rs | 41 +++++-------- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 6980c87e..69b7371d 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -332,28 +332,60 @@ 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, /// 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. @@ -1107,13 +1139,11 @@ pub fn execute_classification_write_plan( rollback_operations: Vec::new(), }; } - let Some(server_id) = plan + let server_id = plan .server_id .as_deref() .filter(|server_id| !server_id.trim().is_empty()) - else { - return preflight_failure_receipt(plan, None); - }; + .expect("execute plans are built with a nonblank server identity"); for operation in &plan.operations { let Ok(state) = preflight(&operation.item_key) else { diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 37c09ad4..678e1457 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -25,13 +25,13 @@ fn execution_preflights_every_item_and_returns_reversible_partial_failure() { |item_key| { preflighted.push(item_key.to_owned()); let operation = plan - .operations + .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() { + library_version: if preflighted.len() > plan.operations().len() { 43 } else { 42 @@ -106,7 +106,7 @@ fn execution_reconciles_a_committed_write_after_its_response_is_lost() { if item_key == "B" { b_reads += 1; if b_reads == 2 { - let operation = &plan.operations[1]; + let operation = &plan.operations()[1]; return Ok::<_, ()>(ClassificationItemState { server_id: "server-1".into(), library_version: 44, @@ -147,7 +147,7 @@ fn execution_names_an_item_when_failed_write_reconciliation_is_unavailable() { &plan, |item_key| { reads += 1; - if reads > plan.operations.len() { + if reads > plan.operations().len() { Err(()) } else { Ok(preflight_state(&plan, item_key)) @@ -168,13 +168,13 @@ fn preflight_state( item_key: &str, ) -> ClassificationItemState { let operation = plan - .operations + .operations() .iter() .find(|operation| operation.item_key == item_key) .unwrap(); ClassificationItemState { server_id: "server-1".into(), - library_version: plan.library_version, + library_version: plan.library_version(), item_key: item_key.into(), item_version: operation.item_version, collection_keys: operation.before_collection_keys.clone(), @@ -241,19 +241,6 @@ fn execution_fails_closed_for_each_preflight_mismatch() { receipt.outcome, ClassificationWriteOutcome::PreflightFailure ); - - let mut missing_server_plan = plan.clone(); - missing_server_plan.server_id = Some(" ".into()); - let receipt = execute_classification_write_plan( - &missing_server_plan, - |_| -> Result { panic!("missing server must fail first") }, - |_| -> Result { panic!("missing server must fail first") }, - ); - assert_eq!( - receipt.outcome, - ClassificationWriteOutcome::PreflightFailure - ); - assert_eq!(receipt.failed_item_key, None); } fn applied_state( @@ -399,18 +386,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] @@ -714,7 +701,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/docs/PRD.md b/docs/PRD.md index 03f9363d..a6a46b2e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -60,7 +60,7 @@ Read one immutable Zotero Local API library-version snapshot and propose exactly For every connected duplicate component, accept externally verified steward decisions selecting one component-level canonical item. Produce a local-only manifest that binds the decisions to the raw snapshot, its complete item-key/item-version coordinates, and exact duplicate-candidate membership, and records 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. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions. Cross-item atomicity is not claimed. diff --git a/docs/TRD.md b/docs/TRD.md index a711e29b..5994c5c3 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,4 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The 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/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index c22253d5..c907c1e8 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -10,7 +10,7 @@ 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. 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. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 92aba434..feceaec7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,7 +44,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; automatic-tag type is preserved and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 30d06b872f3e192e53906e2c087a3a8a71cbd976 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:48:19 +0900 Subject: [PATCH 12/26] test(zotero): expose receipt evidence invariants --- .../classification_write_receipt_contract.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs 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..39a7157e --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -0,0 +1,188 @@ +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 { + 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 { + 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(), + 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(), + } +} + +#[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_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.library_version, 42); + assert_eq!(receipt.rule_revision, report.rule_revision); + assert_eq!(receipt.snapshot_digest, report.snapshot_digest); +} + +#[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 confirmed_unexpected_mutation_retains_known_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_eq!(receipt.failed_item_key.as_deref(), Some("A")); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("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!(receipt.rollback_operations[0].collection_keys.is_empty()); + assert!(receipt.rollback_operations[0].tags.is_empty()); +} From 41ba494261408d284d85eea8c56132c4ed27cb6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:34:20 +0900 Subject: [PATCH 13/26] fix: bind write receipts to reviewed plan --- crates/conceptweave-zotero/src/lib.rs | 56 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 69b7371d..5de15b55 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -452,6 +452,18 @@ pub enum ClassificationWriteOutcome { /// 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 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, /// Overall execution outcome. pub outcome: ClassificationWriteOutcome, /// Items whose post-write state was verified, in application order. @@ -1131,11 +1143,21 @@ pub fn execute_classification_write_plan( ) -> 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(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), outcome: ClassificationWriteOutcome::DryRun, applied_item_keys: Vec::new(), failed_item_key: None, indeterminate_item_key: None, - not_attempted_item_keys: Vec::new(), + not_attempted_item_keys: plan + .operations + .iter() + .map(|operation| operation.item_key.clone()) + .collect(), rollback_operations: Vec::new(), }; } @@ -1178,7 +1200,7 @@ pub fn execute_classification_write_plan( let reconciled_before = reconciled_state.as_ref().is_some_and(|state| { matches_before_state(state, server_id, current_library_version, operation) }); - if let Some(state) = reconciled_state.filter(|_| reconciled_after) { + if let Some(state) = reconciled_state.as_ref().filter(|_| reconciled_after) { applied_item_keys.push(operation.item_key.clone()); rollback_operations.push(ClassificationRollbackOperation { item_key: operation.item_key.clone(), @@ -1186,6 +1208,18 @@ pub fn execute_classification_write_plan( collection_keys: operation.rollback_collection_keys.clone(), tags: operation.rollback_tags.clone(), }); + } else if let Some(state) = reconciled_state.as_ref().filter(|state| { + state.server_id == server_id + && state.library_version > current_library_version + && state.item_key == operation.item_key + && state.item_version > operation.item_version + }) { + 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(); return partial_failure_receipt( @@ -1207,6 +1241,12 @@ pub fn execute_classification_write_plan( } rollback_operations.reverse(); ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), outcome: ClassificationWriteOutcome::Applied, applied_item_keys, failed_item_key: None, @@ -1253,6 +1293,12 @@ fn preflight_failure_receipt( 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(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), outcome: ClassificationWriteOutcome::PreflightFailure, applied_item_keys: Vec::new(), failed_item_key: failed_item_key.map(str::to_owned), @@ -1274,6 +1320,12 @@ fn partial_failure_receipt( indeterminate_item_key: Option<&str>, ) -> ClassificationWriteReceipt { ClassificationWriteReceipt { + review_id: plan.review_id.clone(), + authority_receipt: plan.authority_receipt.clone(), + server_id: plan.server_id.clone(), + library_version: plan.library_version, + rule_revision: plan.rule_revision.clone(), + snapshot_digest: plan.snapshot_digest.clone(), outcome: ClassificationWriteOutcome::PartialFailure, applied_item_keys, failed_item_key: Some(plan.operations[failed_index].item_key.clone()), From ec6bdc5025d51ade6e42fea476230f69990bdd7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:36:27 +0900 Subject: [PATCH 14/26] docs: bind write outcomes to reviewed evidence --- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/UBIQUITOUS_LANGUAGE.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 4 ++-- docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index a6a46b2e..ff7e8774 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -62,7 +62,7 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. Execution-critical plan state is immutable outside the owner crate, so callers cannot turn a dry run into execution or alter validated operations. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. -For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions. Cross-item atomicity is not claimed. +For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt 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 bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index 5994c5c3..2376348d 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,4 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +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, library, rule, and snapshot 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 verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost. An unexpected state stays indeterminate, but receives an inverse operation only when the same server/item and newer library/item versions prove a safe rollback target. 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 51d88e50..71b8c54e 100644 --- a/docs/UBIQUITOUS_LANGUAGE.md +++ b/docs/UBIQUITOUS_LANGUAGE.md @@ -18,7 +18,7 @@ | 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 dry-run artifact containing exact preconditions and before/after/rollback metadata; not proof of execution. | -| Classification Write Receipt | Secret-free result that distinguishes dry-run, verified completion, preflight failure, and partial failure while retaining applied, failed, untouched, and rollback coordinates. | +| 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. | | Reviewed Duplicate Merge Set | Complete steward decisions selecting one consistent canonical item across every overlapping duplicate group in a snapshot. | | Authority Receipt | Opaque proof checked by the Governance & Publication boundary; it contains no reviewer identity or credential. | diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index c907c1e8..a7fc388f 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -10,9 +10,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, while private fields and read-only accessors prevent external callers from mutating validated execution state. 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. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +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 write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation. An unexpected mutation remains indeterminate but retains an inverse operation only when the same server/item identity and newer library/item revisions establish a safe conditional rollback target. An unprovable state is named explicitly and requires operator reconciliation. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index feceaec7..6b4acaec 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,7 +44,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. Every secret-free execution receipt retains the exact review, authority, server, library, rule, and snapshot coordinates. Dry-run calls nothing and reports all operations as not attempted. Execute mode preflights every item, stops at the first failure, and reconciles a lost or invalid response with a same-boundary read. Proven applied states receive rollback coordinates; identity- and version-confirmed unexpected mutations stay indeterminate while retaining a safe conditional inverse. Unprovable current-item state is reported as indeterminate without a false rollback claim. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 6c4a99a89ce943e703cbf2efd857977379deab2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:36:46 +0900 Subject: [PATCH 15/26] style: format write receipt contract tests --- .../classification_write_receipt_contract.rs | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index 39a7157e..b86e383b 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -102,13 +102,9 @@ fn preflight_state( #[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 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") }, @@ -126,13 +122,9 @@ fn every_receipt_binds_to_the_reviewed_plan_coordinates() { #[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 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") }, @@ -146,13 +138,9 @@ fn dry_run_receipt_enumerates_every_operation_as_not_attempted() { #[test] fn confirmed_unexpected_mutation_retains_known_inverse_rollback() { let report = classification_report(); - let plan = build_classification_write_plan( - &report, - &reviewed(&report), - WriteMode::Execute, - |_| true, - ) - .unwrap(); + let plan = + build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) + .unwrap(); let initial_preflight_count = plan.operations().len(); let mut reads = 0usize; From ff224b9da7ed8f561b961058bd63100a83da14c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:37:38 +0900 Subject: [PATCH 16/26] test: reject rollback identity drift --- .../classification_write_receipt_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index b86e383b..0a1b2416 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -174,3 +174,42 @@ fn confirmed_unexpected_mutation_retains_known_inverse_rollback() { assert!(receipt.rollback_operations[0].collection_keys.is_empty()); assert!(receipt.rollback_operations[0].tags.is_empty()); } + +#[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()); + } +} From 78147f52ab7863b224749cf19a1effaadb5873be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:53:46 +0900 Subject: [PATCH 17/26] test: require Zotero version receipt binding --- .../classification_write_receipt_contract.rs | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index 0a1b2416..6f442b3c 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -99,6 +99,19 @@ fn preflight_state( } } +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); +} + #[test] fn every_receipt_binds_to_the_reviewed_plan_coordinates() { let report = classification_report(); @@ -111,12 +124,42 @@ fn every_receipt_binds_to_the_reviewed_plan_coordinates() { |_| -> Result { panic!("dry-run must not write") }, ); - 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.library_version, 42); - assert_eq!(receipt.rule_revision, report.rule_revision); - assert_eq!(receipt.snapshot_digest, report.snapshot_digest); + 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] @@ -166,6 +209,7 @@ fn confirmed_unexpected_mutation_retains_known_inverse_rollback() { ); 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_eq!(receipt.rollback_operations.len(), 1); From de44b16747792b40c4b907406b8413e592f88379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:54:03 +0900 Subject: [PATCH 18/26] fix: retain Zotero version in write receipts --- crates/conceptweave-zotero/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 5de15b55..06dcad48 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -458,6 +458,8 @@ pub struct ClassificationWriteReceipt { 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. @@ -1146,6 +1148,7 @@ pub fn execute_classification_write_plan( 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(), @@ -1244,6 +1247,7 @@ pub fn execute_classification_write_plan( 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(), @@ -1296,6 +1300,7 @@ fn preflight_failure_receipt( 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(), @@ -1323,6 +1328,7 @@ fn partial_failure_receipt( 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(), From 1c61f56cabecc76432e6d1799d8d52f16f0e5245 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:54:46 +0900 Subject: [PATCH 19/26] docs: include Zotero version in receipt provenance --- docs/TRD.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index 2376348d..422c5451 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,4 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, library, rule, and snapshot 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 verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost. An unexpected state stays indeterminate, but receives an inverse operation only when the same server/item and newer library/item versions prove a safe rollback target. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +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, and snapshot 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 verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost. An unexpected state stays indeterminate, but receives an inverse operation only when the same server/item and newer library/item versions prove a safe rollback target. 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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6b4acaec..05fe17d7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -44,7 +44,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. Every secret-free execution receipt retains the exact review, authority, server, library, rule, and snapshot coordinates. Dry-run calls nothing and reports all operations as not attempted. Execute mode preflights every item, stops at the first failure, and reconciles a lost or invalid response with a same-boundary read. Proven applied states receive rollback coordinates; identity- and version-confirmed unexpected mutations stay indeterminate while retaining a safe conditional inverse. Unprovable current-item state is reported as indeterminate without a false rollback claim. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. Every secret-free execution receipt retains the exact review, authority, server, Zotero version, library, rule, and snapshot coordinates. Dry-run calls nothing and reports all operations as not attempted. Execute mode preflights every item, stops at the first failure, and reconciles a lost or invalid response with a same-boundary read. Proven applied states receive rollback coordinates; identity- and version-confirmed unexpected mutations stay indeterminate while retaining a safe conditional inverse. Unprovable current-item state is reported as indeterminate without a false rollback claim. Synthetic fixtures verify these contracts. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires an authenticated Zotero 10+ HTTP transport plus approved live partial-failure and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From 4ae166c501c83f6508011bcd31526815b69dd391 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:54:09 +0900 Subject: [PATCH 20/26] test(research): adopt captured-source receipt fixtures Signed-off-by: Seongho Bae --- .../tests/classification_write_receipt_contract.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index 6f442b3c..e4bd9552 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -18,6 +18,7 @@ fn classification_report() -> conceptweave_zotero::ClassificationReport { 42, vec![ ZoteroItem { + source_record: None, key: "B".into(), version: 9, data: ItemData { @@ -31,6 +32,7 @@ fn classification_report() -> conceptweave_zotero::ClassificationReport { }, }, ZoteroItem { + source_record: None, key: "A".into(), version: 7, data: ItemData { From e8b4c06a1cefa3827686b677af9d13557c6d1d12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:22:33 +0900 Subject: [PATCH 21/26] test(zotero): require source scope in every execution receipt outcome --- .../tests/classification_write_receipt_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index d669d310..50c25329 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -113,6 +113,10 @@ fn assert_receipt_binding( assert_eq!(receipt.library_version, 42); assert_eq!(receipt.rule_revision, report.rule_revision); assert_eq!(receipt.snapshot_digest, report.snapshot_digest); + assert_eq!( + serde_json::to_value(receipt).unwrap()["proposal_digest"], + conceptweave_zotero::classification_proposal_digest(report) + ); } #[test] From b91ad9fc36be7aad5f1d05226add7d205b808b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:23:39 +0900 Subject: [PATCH 22/26] fix(zotero): preserve reviewed source scope in all execution receipts --- crates/conceptweave-zotero/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 4d80008b..a59825d9 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -509,6 +509,8 @@ pub struct ClassificationWriteReceipt { 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. @@ -1325,6 +1327,7 @@ pub fn execute_classification_write_plan( 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, @@ -1424,6 +1427,7 @@ pub fn execute_classification_write_plan( 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, @@ -1477,6 +1481,7 @@ fn preflight_failure_receipt( 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), @@ -1505,6 +1510,7 @@ fn partial_failure_receipt( 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()), From 646a10c6359529c173dea71cda15ba27b325b637 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:24:32 +0900 Subject: [PATCH 23/26] test(zotero): reject causal certainty from post-failure observations Retain all scenarios while correcting unsafe prior assertions: matching before or after metadata and unrelated newer mutations cannot prove the submitted request ended or authorize rollback. --- .../tests/classification_write_plan.rs | 18 ++++++++++++------ .../classification_write_receipt_contract.rs | 15 +++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 2a3c40eb..79243447 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -143,7 +143,7 @@ fn execution_preflights_every_item_and_returns_reversible_partial_failure() { 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, None); + 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); @@ -175,7 +175,7 @@ fn dry_run_execution_never_calls_the_write_boundary() { } #[test] -fn execution_reconciles_a_committed_write_after_its_response_is_lost() { +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) @@ -211,10 +211,16 @@ fn execution_reconciles_a_committed_write_after_its_response_is_lost() { assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); assert_eq!(receipt.failed_item_key.as_deref(), Some("B")); - assert_eq!(receipt.indeterminate_item_key, None); - assert_eq!(receipt.applied_item_keys, ["A", "B"]); - assert_eq!(receipt.rollback_operations[0].item_key, "B"); - assert_eq!(receipt.rollback_operations[0].item_version, 10); + 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!(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] diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index 50c25329..f45fd8a7 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -186,7 +186,7 @@ fn dry_run_receipt_enumerates_every_operation_as_not_attempted() { } #[test] -fn confirmed_unexpected_mutation_retains_known_inverse_rollback() { +fn unexpected_observation_cannot_authorize_inverse_rollback() { let report = classification_report(); let plan = build_classification_write_plan(&report, &reviewed(&report), WriteMode::Execute, |_| true) @@ -219,11 +219,14 @@ fn confirmed_unexpected_mutation_retains_known_inverse_rollback() { 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_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!(receipt.rollback_operations[0].collection_keys.is_empty()); - assert!(receipt.rollback_operations[0].tags.is_empty()); + 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] From c09d101b3a432b4003310b8c3110a5ec6ee81002 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:25:17 +0900 Subject: [PATCH 24/26] fix(zotero): retain uncertain requests without inferred rollback authority Post-failure reads are observations only. Preserve exact submitted requests and prior verified operations; never infer causal completion, termination or inverse authority from matching metadata. --- crates/conceptweave-zotero/src/lib.rs | 49 +++++++++++---------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index a59825d9..fadb8dcd 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -519,6 +519,10 @@ pub struct ClassificationWriteReceipt { 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. @@ -1332,6 +1336,8 @@ pub fn execute_classification_write_plan( 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() @@ -1373,40 +1379,16 @@ pub fn execute_classification_write_plan( }); let Some(state) = verified_state else { let reconciled_state = preflight(&operation.item_key).ok(); - let reconciled_after = reconciled_state.as_ref().is_some_and(|state| { - matches_after_state(state, server_id, current_library_version, operation) - }); - let reconciled_before = reconciled_state.as_ref().is_some_and(|state| { - matches_before_state(state, server_id, current_library_version, operation) - }); - if let Some(state) = reconciled_state.as_ref().filter(|_| reconciled_after) { - 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(), - }); - } else if let Some(state) = reconciled_state.as_ref().filter(|state| { - state.server_id == server_id - && state.library_version > current_library_version - && state.item_key == operation.item_key - && state.item_version > operation.item_version - }) { - 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(), - }); - } + // 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, - (!reconciled_after && !reconciled_before).then_some(operation.item_key.as_str()), + request, + reconciled_state, ); }; current_library_version = state.library_version; @@ -1432,6 +1414,8 @@ pub fn execute_classification_write_plan( 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, } @@ -1486,6 +1470,8 @@ fn preflight_failure_receipt( 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() @@ -1500,7 +1486,8 @@ fn partial_failure_receipt( failed_index: usize, applied_item_keys: Vec, rollback_operations: Vec, - indeterminate_item_key: Option<&str>, + indeterminate_request: ClassificationWriteRequest, + reconciliation_observation: Option, ) -> ClassificationWriteReceipt { ClassificationWriteReceipt { review_id: plan.review_id.clone(), @@ -1514,7 +1501,9 @@ fn partial_failure_receipt( outcome: ClassificationWriteOutcome::PartialFailure, applied_item_keys, failed_item_key: Some(plan.operations[failed_index].item_key.clone()), - indeterminate_item_key: indeterminate_item_key.map(str::to_owned), + indeterminate_item_key: Some(indeterminate_request.item_key.clone()), + indeterminate_request: Some(indeterminate_request), + reconciliation_observation, not_attempted_item_keys: plan .operations .iter() From e1696306f3ec6f7e9d5ab4362276b58e11b29022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:26:23 +0900 Subject: [PATCH 25/26] test(zotero): verify exact uncertain request and absent observation states --- .../tests/classification_write_plan.rs | 11 +++++++++++ .../tests/classification_write_receipt_contract.rs | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 79243447..86ac1ec4 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -181,6 +181,7 @@ fn matching_observation_does_not_prove_a_lost_write_completed() { 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| { @@ -201,6 +202,7 @@ fn matching_observation_does_not_prove_a_lost_write_completed() { Ok::<_, ()>(preflight_state(&plan, item_key)) }, |request| { + submitted_requests.push(request.clone()); if request.item_key == "B" { Err(()) } else { @@ -217,6 +219,10 @@ fn matching_observation_does_not_prove_a_lost_write_completed() { 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); @@ -246,6 +252,11 @@ fn execution_names_an_item_when_failed_write_reconciliation_is_unavailable() { 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"]); } diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index f45fd8a7..7b9338c8 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -113,6 +113,10 @@ fn assert_receipt_binding( 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) From 42ddd81adf8d994f67a30f0c6b8383d637073c72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:28:17 +0900 Subject: [PATCH 26/26] docs(research): record execution uncertainty repair and evidence scope --- CHANGELOG.md | 3 +++ docs/PRD.md | 5 ++++ docs/TRD.md | 10 ++++++- docs/UBIQUITOUS_LANGUAGE.md | 1 + docs/UML.md | 1 + docs/adr/0007-reviewed-zotero-write-plan.md | 23 +++++++++++++++- docs/product-technical-gap-baseline.md | 30 +++++++++++++++++++++ 7 files changed, 71 insertions(+), 2 deletions(-) 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/docs/PRD.md b/docs/PRD.md index ae529402..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 diff --git a/docs/TRD.md b/docs/TRD.md index 61667ff9..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; 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, and snapshot 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 verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost. An unexpected state stays indeterminate, but receives an inverse operation only when the same server/item and newer library/item versions prove a safe rollback target. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +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 0d49f8d3..1c36c76e 100644 --- a/docs/UBIQUITOUS_LANGUAGE.md +++ b/docs/UBIQUITOUS_LANGUAGE.md @@ -20,6 +20,7 @@ | 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 b882e566..1af557e7 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -79,5 +79,6 @@ sequenceDiagram 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 b46687ac..082e60ba 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -16,7 +16,7 @@ Issue #8 requires classification changes to default to dry-run, preserve complet 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. 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 write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation. An unexpected mutation remains indeterminate but retains an inverse operation only when the same server/item identity and newer library/item revisions establish a safe conditional rollback target. An unprovable state is named explicitly and requires operator reconciliation. The API key remains in a future authenticated adapter. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +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 @@ -52,6 +52,27 @@ adopt the required field without deriving fresh authority from serialized plans. ## 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