From a3916b31527d4dd4d89ad7579bc4eab6193ba13f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:30:34 +0900 Subject: [PATCH 01/12] test(zotero): specify authenticated local transport --- crates/conceptweave-zotero/src/lib.rs | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 69b7371d..1a80427b 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1861,10 +1861,148 @@ mod tests { use super::*; use std::io::{Read, Write}; use std::net::TcpListener; + use std::io::{Read, Write}; use std::thread; static LOCAL_API_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn serve(responses: Vec<&'static str>) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + responses + .into_iter() + .map(|response| { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = vec![0; 16 * 1024]; + let mut length = 0; + loop { + length += stream.read(&mut bytes[length..]).unwrap(); + let request = String::from_utf8_lossy(&bytes[..length]); + let headers_end = request.find("\r\n\r\n").unwrap_or(usize::MAX); + let content_length = request + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if headers_end != usize::MAX && length >= headers_end + 4 + content_length { + break; + } + } + stream.write_all(response.as_bytes()).unwrap(); + String::from_utf8(bytes[..length].to_vec()).unwrap() + }) + .collect() + }); + (format!("http://{address}/api/users/0/items"), handle) + } + + fn item_response(server_id: &str, library_version: u64, item_version: u64) -> String { + let body = format!( + r#"{{"key":"ABCD2345","version":{item_version},"data":{{"itemType":"book","collections":["BCDE3456"],"tags":[{{"tag":"kept","type":1}}]}}}}"# + ); + format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: {server_id}\r\nLast-Modified-Version: {library_version}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn transport(base: String) -> Zotero10LocalAdapter { + Zotero10LocalAdapter::new_with_base("top-secret-key", "server-10", base).unwrap() + } + + #[test] + fn zotero10_get_uses_exact_item_route_and_server_partition() { + let response = item_response("server-10", 42, 7); + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let state = transport(base).get_item("ABCD2345").unwrap(); + assert_eq!(state.server_id, "server-10"); + assert_eq!(state.library_version, 42); + assert_eq!(state.item_version, 7); + assert_eq!(state.collection_keys, ["BCDE3456"]); + assert_eq!(state.tags[0].tag_type, Some(1)); + let requests = server.join().unwrap(); + assert!(requests[0].starts_with("GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n")); + assert!(requests[0].contains("zotero-api-version: 3\r\n")); + assert!(requests[0].contains("zotero-server-id: server-10\r\n")); + assert!(!requests[0].contains("top-secret-key")); + } + + #[test] + fn zotero10_patch_replaces_complete_arrays_then_reads_verified_state() { + let read = item_response("server-10", 43, 8); + let (base, server) = serve(vec![ + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + Box::leak(read.into_boxed_str()), + ]); + let request = ClassificationWriteRequest { + server_id: "server-10".into(), + library_version: 42, + item_key: "ABCD2345".into(), + item_version: 7, + collection_keys: vec!["BCDE3456".into()], + tags: vec![ItemTag { tag: "kept".into(), tag_type: Some(1) }], + }; + let state = transport(base).patch_item(&request).unwrap(); + assert_eq!(state.library_version, 43); + let requests = server.join().unwrap(); + assert!(requests[0].starts_with("PATCH /api/users/0/items/ABCD2345 HTTP/1.1\r\n")); + assert!(requests[0].contains("zotero-api-key: top-secret-key\r\n")); + assert!(requests[0].contains("zotero-server-id: server-10\r\n")); + assert!(requests[0].contains("if-unmodified-since-version: 7\r\n")); + assert!(requests[0].contains("content-type: application/json\r\n")); + assert!(requests[0].ends_with(r#"{"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}"#)); + assert!(requests[1].starts_with("GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n")); + } + + #[test] + fn zotero10_transport_rejects_stale_non_success_and_server_mismatch() { + for response in [ + "HTTP/1.1 412 Precondition Failed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), + item_response("other-server", 42, 7), + ] { + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let error = transport(base).get_item("ABCD2345").unwrap_err(); + assert!(matches!(error, ZoteroTransportError::RequestFailed | ZoteroTransportError::ServerMismatch)); + server.join().unwrap(); + } + } + + #[test] + fn zotero10_transport_rejects_invalid_keys_credentials_and_bounded_bodies() { + assert_eq!( + Zotero10LocalAdapter::new(" ", "server-10").unwrap_err(), + ZoteroTransportError::InvalidCredentials + ); + assert_eq!( + Zotero10LocalAdapter::new("secret", " ").unwrap_err(), + ZoteroTransportError::InvalidCredentials + ); + let adapter = Zotero10LocalAdapter::new("secret", "server-10").unwrap(); + for key in ["ABCD234", "ABCD2340", "abcd2345", "ABCD2345/../X"] { + assert_eq!(adapter.get_item(key).unwrap_err(), ZoteroTransportError::InvalidItemKey); + } + + let malformed = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{"; + let (base, server) = serve(vec![malformed]); + assert_eq!(transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse); + server.join().unwrap(); + + let oversized = "x".repeat((MAX_ITEM_RESPONSE_BYTES + 1) as usize); + let response = format!("HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{oversized}", oversized.len()); + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!(transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse); + server.join().unwrap(); + } + + #[test] + fn zotero10_transport_never_formats_or_serializes_the_key() { + let adapter = Zotero10LocalAdapter::new("top-secret-key", "server-10").unwrap(); + assert!(!std::any::type_name_of_val(&adapter).contains("top-secret-key")); + assert_eq!(format!("{:?}", ZoteroTransportError::RequestFailed), "RequestFailed"); + } + fn item(key: &str, item_type: &str, title: &str, doi: &str, parent: &str) -> ZoteroItem { ZoteroItem { key: key.into(), From 9d83a1da6f12cc4b785596289286ec48e7de769d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:31:54 +0900 Subject: [PATCH 02/12] feat(zotero): add authenticated local transport --- crates/conceptweave-zotero/src/lib.rs | 300 +++++++++++++++++- docs/PRD.md | 2 + docs/TRD.md | 4 +- docs/adr/0007-reviewed-zotero-write-plan.md | 8 +- docs/doctoring/REFERENCES.md | 4 + .../RESEARCH_CAPABILITY_TRACEABILITY.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 7 files changed, 300 insertions(+), 22 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 1a80427b..0954b758 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -18,6 +18,7 @@ const PAGE_LIMIT: usize = 100; const MAX_PAGE_BYTES: u64 = 8 * 1024 * 1024; const MAX_SNAPSHOT_ITEMS: usize = 50_000; const MAX_SNAPSHOT_BYTES: u64 = 256 * 1024 * 1024; +const MAX_ITEM_RESPONSE_BYTES: u64 = 1024 * 1024; const LOCAL_API: &str = "http://127.0.0.1:23119/api/users/0/items"; #[cfg(test)] @@ -405,6 +406,187 @@ pub struct ClassificationItemState { pub tags: Vec, } +/// Secret-free failure returned by the authenticated Zotero 10 Local API adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZoteroTransportError { + /// The caller did not provide a usable API key and server identity. + InvalidCredentials, + /// The item key is not an official eight-character Zotero object key. + InvalidItemKey, + /// The Local API rejected the request or could not be reached. + RequestFailed, + /// The response came from a different Zotero database. + ServerMismatch, + /// The response headers or bounded JSON body were invalid. + InvalidResponse, +} + +/// Minimal authenticated adapter for Zotero 10+ Local API item metadata writes. +/// +/// Credentials remain private and this type deliberately implements neither +/// [`Debug`] nor [`Serialize`]. +pub struct Zotero10LocalAdapter { + api_key: String, + server_id: String, + base: String, + agent: ureq::Agent, +} + +impl Zotero10LocalAdapter { + /// Creates an adapter pinned to Zotero's loopback production endpoint. + pub fn new( + api_key: impl Into, + server_id: impl Into, + ) -> Result { + Self::build(api_key.into(), server_id.into(), LOCAL_API.to_owned()) + } + + #[cfg(test)] + fn new_with_base( + api_key: impl Into, + server_id: impl Into, + base: String, + ) -> Result { + Self::build(api_key.into(), server_id.into(), base) + } + + fn build( + api_key: String, + server_id: String, + base: String, + ) -> Result { + if api_key.trim().is_empty() || server_id.trim().is_empty() { + return Err(ZoteroTransportError::InvalidCredentials); + } + let config = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(30))) + .timeout_connect(Some(Duration::from_secs(2))) + .timeout_recv_response(Some(Duration::from_secs(10))) + .timeout_recv_body(Some(Duration::from_secs(10))) + .max_redirects(0) + .build(); + Ok(Self { + api_key, + server_id, + base, + agent: ureq::Agent::new_with_config(config), + }) + } + + /// Reads one item's current collection, tag, and version coordinates. + pub fn get_item( + &self, + item_key: &str, + ) -> Result { + validate_item_key(item_key)?; + let url = format!("{}/{item_key}?format=json&include=data", self.base); + let response = self + .agent + .get(&url) + .header("Zotero-API-Version", SUPPORTED_API_VERSION_HEADER) + .header("Zotero-Server-ID", &self.server_id) + .call() + .map_err(|_| ZoteroTransportError::RequestFailed)?; + self.read_state(response, item_key) + } + + /// Replaces the complete collection and tag arrays, then reads verified state. + pub fn patch_item( + &self, + request: &ClassificationWriteRequest, + ) -> Result { + validate_item_key(&request.item_key)?; + if request.server_id != self.server_id { + return Err(ZoteroTransportError::ServerMismatch); + } + #[derive(Serialize)] + struct Patch<'a> { + collections: &'a [String], + tags: &'a [ItemTag], + } + let body = serde_json::to_string(&Patch { + collections: &request.collection_keys, + tags: &request.tags, + }) + .map_err(|_| ZoteroTransportError::InvalidResponse)?; + let url = format!("{}/{}", self.base, request.item_key); + let response = self + .agent + .patch(&url) + .header("Zotero-API-Version", SUPPORTED_API_VERSION_HEADER) + .header("Zotero-API-Key", &self.api_key) + .header("Zotero-Server-ID", &self.server_id) + .header( + "If-Unmodified-Since-Version", + &request.item_version.to_string(), + ) + .header("Content-Type", "application/json") + .send(body) + .map_err(|_| ZoteroTransportError::RequestFailed)?; + if response.status() != ureq::http::StatusCode::NO_CONTENT { + return Err(ZoteroTransportError::RequestFailed); + } + let response_server = response + .headers() + .get("Zotero-Server-ID") + .and_then(|value| value.to_str().ok()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + if response_server != self.server_id { + return Err(ZoteroTransportError::ServerMismatch); + } + self.get_item(&request.item_key) + } + + fn read_state( + &self, + mut response: ureq::http::Response, + requested_key: &str, + ) -> Result { + let response_server = response + .headers() + .get("Zotero-Server-ID") + .and_then(|value| value.to_str().ok()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + if response_server != self.server_id { + return Err(ZoteroTransportError::ServerMismatch); + } + let library_version = response + .headers() + .get("Last-Modified-Version") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + let body = response + .body_mut() + .with_config() + .limit(MAX_ITEM_RESPONSE_BYTES) + .read_to_string() + .map_err(|_| ZoteroTransportError::InvalidResponse)?; + let item: ZoteroItem = + serde_json::from_str(&body).map_err(|_| ZoteroTransportError::InvalidResponse)?; + if item.key != requested_key { + return Err(ZoteroTransportError::InvalidResponse); + } + Ok(ClassificationItemState { + server_id: self.server_id.clone(), + library_version, + item_key: item.key, + item_version: item.version, + collection_keys: item.data.collections, + tags: item.data.tags, + }) + } +} + +fn validate_item_key(item_key: &str) -> Result<(), ZoteroTransportError> { + const ALPHABET: &[u8] = b"23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"; + if item_key.len() == 8 && item_key.bytes().all(|byte| ALPHABET.contains(&byte)) { + Ok(()) + } else { + Err(ZoteroTransportError::InvalidItemKey) + } +} + /// One conditional complete-state replacement passed to an authenticated adapter. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ClassificationWriteRequest { @@ -1861,7 +2043,6 @@ mod tests { use super::*; use std::io::{Read, Write}; use std::net::TcpListener; - use std::io::{Read, Write}; use std::thread; static LOCAL_API_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -1922,7 +2103,11 @@ mod tests { assert_eq!(state.collection_keys, ["BCDE3456"]); assert_eq!(state.tags[0].tag_type, Some(1)); let requests = server.join().unwrap(); - assert!(requests[0].starts_with("GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n")); + assert!( + requests[0].starts_with( + "GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n" + ) + ); assert!(requests[0].contains("zotero-api-version: 3\r\n")); assert!(requests[0].contains("zotero-server-id: server-10\r\n")); assert!(!requests[0].contains("top-secret-key")); @@ -1932,7 +2117,7 @@ mod tests { fn zotero10_patch_replaces_complete_arrays_then_reads_verified_state() { let read = item_response("server-10", 43, 8); let (base, server) = serve(vec![ - "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + "HTTP/1.1 204 No Content\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", Box::leak(read.into_boxed_str()), ]); let request = ClassificationWriteRequest { @@ -1941,7 +2126,10 @@ mod tests { item_key: "ABCD2345".into(), item_version: 7, collection_keys: vec!["BCDE3456".into()], - tags: vec![ItemTag { tag: "kept".into(), tag_type: Some(1) }], + tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], }; let state = transport(base).patch_item(&request).unwrap(); assert_eq!(state.library_version, 43); @@ -1951,20 +2139,73 @@ mod tests { assert!(requests[0].contains("zotero-server-id: server-10\r\n")); assert!(requests[0].contains("if-unmodified-since-version: 7\r\n")); assert!(requests[0].contains("content-type: application/json\r\n")); - assert!(requests[0].ends_with(r#"{"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}"#)); - assert!(requests[1].starts_with("GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n")); + assert!( + requests[0] + .ends_with(r#"{"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}"#) + ); + assert!( + requests[1].starts_with( + "GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n" + ) + ); } #[test] fn zotero10_transport_rejects_stale_non_success_and_server_mismatch() { + let stale = + "HTTP/1.1 412 Precondition Failed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![stale]); + let request = ClassificationWriteRequest { + server_id: "server-10".into(), + library_version: 42, + item_key: "ABCD2345".into(), + item_version: 7, + collection_keys: vec![], + tags: vec![], + }; + assert_eq!( + transport(base).patch_item(&request).unwrap_err(), + ZoteroTransportError::RequestFailed + ); + assert!(server.join().unwrap()[0].starts_with("PATCH ")); + + let mut mismatched_request = request.clone(); + mismatched_request.server_id = "other-server".into(); + assert_eq!( + Zotero10LocalAdapter::new("secret", "server-10") + .unwrap() + .patch_item(&mismatched_request) + .unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + + let unexpected_success = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![unexpected_success]); + assert_eq!( + transport(base).patch_item(&request).unwrap_err(), + ZoteroTransportError::RequestFailed + ); + server.join().unwrap(); + + let wrong_server = "HTTP/1.1 204 No Content\r\nZotero-Server-ID: other-server\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![wrong_server]); + assert_eq!( + transport(base).patch_item(&request).unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + server.join().unwrap(); + for response in [ - "HTTP/1.1 412 Precondition Failed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), - "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_owned(), item_response("other-server", 42, 7), ] { let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); let error = transport(base).get_item("ABCD2345").unwrap_err(); - assert!(matches!(error, ZoteroTransportError::RequestFailed | ZoteroTransportError::ServerMismatch)); + assert!(matches!( + error, + ZoteroTransportError::RequestFailed | ZoteroTransportError::ServerMismatch + )); server.join().unwrap(); } } @@ -1972,27 +2213,51 @@ mod tests { #[test] fn zotero10_transport_rejects_invalid_keys_credentials_and_bounded_bodies() { assert_eq!( - Zotero10LocalAdapter::new(" ", "server-10").unwrap_err(), + Zotero10LocalAdapter::new(" ", "server-10").err().unwrap(), ZoteroTransportError::InvalidCredentials ); assert_eq!( - Zotero10LocalAdapter::new("secret", " ").unwrap_err(), + Zotero10LocalAdapter::new("secret", " ").err().unwrap(), ZoteroTransportError::InvalidCredentials ); let adapter = Zotero10LocalAdapter::new("secret", "server-10").unwrap(); for key in ["ABCD234", "ABCD2340", "abcd2345", "ABCD2345/../X"] { - assert_eq!(adapter.get_item(key).unwrap_err(), ZoteroTransportError::InvalidItemKey); + assert_eq!( + adapter.get_item(key).unwrap_err(), + ZoteroTransportError::InvalidItemKey + ); } let malformed = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{"; let (base, server) = serve(vec![malformed]); - assert_eq!(transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); + server.join().unwrap(); + + let wrong_key_body = r#"{"key":"BCDE3456","version":7,"data":{"itemType":"book"}}"#; + let wrong_key_response = format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{wrong_key_body}", + wrong_key_body.len() + ); + let (base, server) = serve(vec![Box::leak(wrong_key_response.into_boxed_str())]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); server.join().unwrap(); let oversized = "x".repeat((MAX_ITEM_RESPONSE_BYTES + 1) as usize); - let response = format!("HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{oversized}", oversized.len()); + let response = format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{oversized}", + oversized.len() + ); let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); - assert_eq!(transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); server.join().unwrap(); } @@ -2000,7 +2265,10 @@ mod tests { fn zotero10_transport_never_formats_or_serializes_the_key() { let adapter = Zotero10LocalAdapter::new("top-secret-key", "server-10").unwrap(); assert!(!std::any::type_name_of_val(&adapter).contains("top-secret-key")); - assert_eq!(format!("{:?}", ZoteroTransportError::RequestFailed), "RequestFailed"); + assert_eq!( + format!("{:?}", ZoteroTransportError::RequestFailed), + "RequestFailed" + ); } fn item(key: &str, item_type: &str, title: &str, doi: &str, parent: &str) -> ZoteroItem { diff --git a/docs/PRD.md b/docs/PRD.md index a6a46b2e..974b00f4 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -64,6 +64,8 @@ Reviewed collection and tag changes default to a local dry-run plan. Each operat For execute-mode plans, the runtime must preflight every item before the first write, 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. +The Zotero 10+ adapter accepts a caller-owned API key and server identity only at runtime. It reads and conditionally patches one official Zotero item key at a time on the fixed loopback Local API, replaces complete collection and typed-tag arrays, and returns a fresh post-write item state. Credentials are neither serializable nor printable. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 write and rollback requirement. + Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index 5994c5c3..065a4f51 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,4 +68,6 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The 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 Zotero 10+ transport is pinned to `http://127.0.0.1:23119/api/users/0/items`, rejects redirects, uses finite timeouts and a 1 MiB single-item response limit, and accepts only official eight-character object keys. The caller supplies nonblank API key and server ID values; the adapter is neither debug-printable nor serializable. Reads send the server partition and API version. Writes send the API key, server partition, API version, content type, and item-version `If-Unmodified-Since-Version`, PATCH only complete `collections` and `tags` arrays, require `204 No Content`, and perform a fresh bounded GET. Errors expose static categories only. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index c907c1e8..840a7313 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -12,16 +12,18 @@ 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. 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. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. + +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. It holds caller-supplied credentials in a non-debuggable, non-serializable value, validates official object keys, disables redirects, bounds response reads, sends the server identity on reads and writes, and sends the API key only on writes. A PATCH replaces the complete collection and typed-tag arrays under the current item-version precondition, accepts only the documented no-content success, and returns a fresh GET state. Static error categories cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences - Review and rollback semantics can be tested on Zotero 9 without changing the library. - Exact before-state checks prevent silent loss of unrelated collections or automatic-tag metadata. -- AC5 is implemented 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. +- AC5 is implemented. AC6 now has deterministic preflight, partial-failure, rollback-receipt, and synthetic authenticated transport evidence. AC6 remains incomplete until approved live Zotero 10 write, partial-failure, and rollback behavior is verified. ## Alternatives considered - 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. +- Treating mock transport coverage as live proof was rejected because no approved Zotero 10 runtime/key write and rollback exercise has been performed. diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 2cbca9b0..0512256b 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -4,6 +4,10 @@ This file records the evidence basis for ConceptWeave architecture decisions. St ## Stable standards / recommendations +Corporation for Digital Scholarship. (2026). *Zotero Local API*. Zotero Documentation. https://www.zotero.org/support/dev/web_api/v3/local_api + +Corporation for Digital Scholarship. (2026). *Zotero Web API write requests*. Zotero Documentation. https://www.zotero.org/support/dev/web_api/v3/write_requests + Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS Simple Knowledge Organization System Reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/ W3C OWL Working Group. (2012). *OWL 2 Web Ontology Language document overview (Second Edition)*. World Wide Web Consortium. https://www.w3.org/TR/owl2-overview/ diff --git a/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md b/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md index 74b10413..a30f66a3 100644 --- a/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md +++ b/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md @@ -101,4 +101,4 @@ The following canonical Consensus records were fetched before recording the corr ## Research intake evidence -The Zotero classifier records item and library revisions plus the exact rule revision for each proposal. Keyword evidence is routing evidence only: unmatched records abstain, duplicate identities remain candidates, and neither path creates authoritative ontology knowledge. Any model-assisted successor must add a `contextual-orchestrator` receipt while preserving the deterministic inputs and steward decision separately. +The Zotero classifier records item and library revisions plus the exact rule revision for each proposal. Keyword evidence is routing evidence only: unmatched records abstain, duplicate identities remain candidates, and neither path creates authoritative ontology knowledge. The official Zotero Local API and Write Requests documentation grounds server partitioning, runtime write authorization, item-version preconditions, official key syntax, and complete-array PATCH semantics. Mock transport fixtures exercise those contracts, while live Zotero 10 write and rollback evidence remains incomplete. Any model-assisted successor must add a `contextual-orchestrator` receipt while preserving the deterministic inputs and steward decision separately. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index bfbaa5a7..1c9091db 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. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires 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. A fixed-loopback Zotero 10 adapter now supplies authenticated, server-pinned, item-version-conditional complete collection/tag replacement with bounded post-write verification; mock TCP fixtures verify its exact wire contract and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 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 8d5a0c5161d5298d6a45a8d76ae9dd9ca5dbe162 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:42:13 +0900 Subject: [PATCH 03/12] fix(zotero): preserve atomic version preconditions --- crates/conceptweave-zotero/src/lib.rs | 273 ++++++++++++------ docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- .../RESEARCH_CAPABILITY_TRACEABILITY.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 6 files changed, 196 insertions(+), 87 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 0954b758..f60371c1 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -479,6 +479,7 @@ impl Zotero10LocalAdapter { item_key: &str, ) -> Result { validate_item_key(item_key)?; + let before = self.library_version()?; let url = format!("{}/{item_key}?format=json&include=data", self.base); let response = self .agent @@ -487,11 +488,23 @@ impl Zotero10LocalAdapter { .header("Zotero-Server-ID", &self.server_id) .call() .map_err(|_| ZoteroTransportError::RequestFailed)?; - self.read_state(response, item_key) + let item = self.read_item(response, item_key)?; + let after = self.library_version()?; + if before != after { + return Err(ZoteroTransportError::InvalidResponse); + } + Ok(ClassificationItemState { + server_id: self.server_id.clone(), + library_version: before, + item_key: item.key, + item_version: item.version, + collection_keys: item.data.collections, + tags: item.data.tags, + }) } - /// Replaces the complete collection and tag arrays, then reads verified state. - pub fn patch_item( + /// Atomically replaces one item's complete collection and tag arrays. + pub fn write_item( &self, request: &ClassificationWriteRequest, ) -> Result { @@ -500,71 +513,55 @@ impl Zotero10LocalAdapter { return Err(ZoteroTransportError::ServerMismatch); } #[derive(Serialize)] - struct Patch<'a> { + struct Write<'a> { + key: &'a str, + version: u64, collections: &'a [String], tags: &'a [ItemTag], } - let body = serde_json::to_string(&Patch { + let body = serde_json::to_string(&[Write { + key: &request.item_key, + version: request.item_version, collections: &request.collection_keys, tags: &request.tags, - }) + }]) .map_err(|_| ZoteroTransportError::InvalidResponse)?; - let url = format!("{}/{}", self.base, request.item_key); - let response = self + let mut response = self .agent - .patch(&url) + .post(&self.base) .header("Zotero-API-Version", SUPPORTED_API_VERSION_HEADER) .header("Zotero-API-Key", &self.api_key) .header("Zotero-Server-ID", &self.server_id) .header( "If-Unmodified-Since-Version", - &request.item_version.to_string(), + &request.library_version.to_string(), ) .header("Content-Type", "application/json") .send(body) .map_err(|_| ZoteroTransportError::RequestFailed)?; - if response.status() != ureq::http::StatusCode::NO_CONTENT { + if response.status() != ureq::http::StatusCode::OK { return Err(ZoteroTransportError::RequestFailed); } - let response_server = response - .headers() - .get("Zotero-Server-ID") - .and_then(|value| value.to_str().ok()) - .ok_or(ZoteroTransportError::InvalidResponse)?; - if response_server != self.server_id { - return Err(ZoteroTransportError::ServerMismatch); - } - self.get_item(&request.item_key) - } - - fn read_state( - &self, - mut response: ureq::http::Response, - requested_key: &str, - ) -> Result { - let response_server = response - .headers() - .get("Zotero-Server-ID") - .and_then(|value| value.to_str().ok()) - .ok_or(ZoteroTransportError::InvalidResponse)?; - if response_server != self.server_id { - return Err(ZoteroTransportError::ServerMismatch); + self.verify_server(response.headers())?; + let library_version = version_header(response.headers())?; + #[derive(Deserialize)] + struct WriteResponse { + successful: BTreeMap, } - let library_version = response - .headers() - .get("Last-Modified-Version") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()) - .ok_or(ZoteroTransportError::InvalidResponse)?; - let body = response - .body_mut() - .with_config() - .limit(MAX_ITEM_RESPONSE_BYTES) - .read_to_string() - .map_err(|_| ZoteroTransportError::InvalidResponse)?; - let item: ZoteroItem = + let body = bounded_body(&mut response)?; + let mut written: WriteResponse = serde_json::from_str(&body).map_err(|_| ZoteroTransportError::InvalidResponse)?; - if item.key != requested_key { + let item = written + .successful + .remove("0") + .filter(|_| written.successful.is_empty()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + if item.key != request.item_key + || item.version <= request.item_version + || item.version != library_version + || item.data.collections != request.collection_keys + || item.data.tags != request.tags + { return Err(ZoteroTransportError::InvalidResponse); } Ok(ClassificationItemState { @@ -576,6 +573,68 @@ impl Zotero10LocalAdapter { tags: item.data.tags, }) } + + fn library_version(&self) -> Result { + let url = format!("{}?format=versions&limit=1", self.base); + let mut response = self + .agent + .get(&url) + .header("Zotero-API-Version", SUPPORTED_API_VERSION_HEADER) + .header("Zotero-Server-ID", &self.server_id) + .call() + .map_err(|_| ZoteroTransportError::RequestFailed)?; + self.verify_server(response.headers())?; + let version = version_header(response.headers())?; + bounded_body(&mut response)?; + Ok(version) + } + + fn read_item( + &self, + mut response: ureq::http::Response, + requested_key: &str, + ) -> Result { + self.verify_server(response.headers())?; + let object_version = version_header(response.headers())?; + let body = bounded_body(&mut response)?; + let item: ZoteroItem = + serde_json::from_str(&body).map_err(|_| ZoteroTransportError::InvalidResponse)?; + if item.key != requested_key || item.version != object_version { + return Err(ZoteroTransportError::InvalidResponse); + } + Ok(item) + } + + fn verify_server(&self, headers: &ureq::http::HeaderMap) -> Result<(), ZoteroTransportError> { + let server = headers + .get("Zotero-Server-ID") + .and_then(|value| value.to_str().ok()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + if server == self.server_id { + Ok(()) + } else { + Err(ZoteroTransportError::ServerMismatch) + } + } +} + +fn version_header(headers: &ureq::http::HeaderMap) -> Result { + headers + .get("Last-Modified-Version") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .ok_or(ZoteroTransportError::InvalidResponse) +} + +fn bounded_body( + response: &mut ureq::http::Response, +) -> Result { + response + .body_mut() + .with_config() + .limit(MAX_ITEM_RESPONSE_BYTES) + .read_to_string() + .map_err(|_| ZoteroTransportError::InvalidResponse) } fn validate_item_key(item_key: &str) -> Result<(), ZoteroTransportError> { @@ -2078,10 +2137,27 @@ mod tests { (format!("http://{address}/api/users/0/items"), handle) } - fn item_response(server_id: &str, library_version: u64, item_version: u64) -> String { + fn library_response(server_id: &str, library_version: u64) -> String { + format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: {server_id}\r\nLast-Modified-Version: {library_version}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ) + } + + fn item_response(server_id: &str, item_version: u64) -> String { let body = format!( r#"{{"key":"ABCD2345","version":{item_version},"data":{{"itemType":"book","collections":["BCDE3456"],"tags":[{{"tag":"kept","type":1}}]}}}}"# ); + format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: {server_id}\r\nLast-Modified-Version: {item_version}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn write_response(server_id: &str, library_version: u64, item_version: u64) -> String { + let item = format!( + r#"{{"key":"ABCD2345","version":{item_version},"data":{{"itemType":"book","collections":["BCDE3456"],"tags":[{{"tag":"kept","type":1}}]}}}}"# + ); + let body = format!(r#"{{"successful":{{"0":{item}}}}}"#); format!( "HTTP/1.1 200 OK\r\nZotero-Server-ID: {server_id}\r\nLast-Modified-Version: {library_version}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() @@ -2094,8 +2170,14 @@ mod tests { #[test] fn zotero10_get_uses_exact_item_route_and_server_partition() { - let response = item_response("server-10", 42, 7); - let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let before = library_response("server-10", 42); + let item = item_response("server-10", 7); + let after = library_response("server-10", 42); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + Box::leak(after.into_boxed_str()), + ]); let state = transport(base).get_item("ABCD2345").unwrap(); assert_eq!(state.server_id, "server-10"); assert_eq!(state.library_version, 42); @@ -2104,22 +2186,33 @@ mod tests { assert_eq!(state.tags[0].tag_type, Some(1)); let requests = server.join().unwrap(); assert!( - requests[0].starts_with( + requests[1].starts_with( "GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n" ) ); - assert!(requests[0].contains("zotero-api-version: 3\r\n")); - assert!(requests[0].contains("zotero-server-id: server-10\r\n")); - assert!(!requests[0].contains("top-secret-key")); + assert!(requests[0].starts_with("GET /api/users/0/items?format=versions&limit=1 ")); + assert!(requests[2].starts_with("GET /api/users/0/items?format=versions&limit=1 ")); + assert!( + requests + .iter() + .all(|request| request.contains("zotero-api-version: 3\r\n")) + ); + assert!( + requests + .iter() + .all(|request| request.contains("zotero-server-id: server-10\r\n")) + ); + assert!( + requests + .iter() + .all(|request| !request.contains("top-secret-key")) + ); } #[test] - fn zotero10_patch_replaces_complete_arrays_then_reads_verified_state() { - let read = item_response("server-10", 43, 8); - let (base, server) = serve(vec![ - "HTTP/1.1 204 No Content\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - Box::leak(read.into_boxed_str()), - ]); + fn zotero10_post_atomically_replaces_complete_arrays() { + let response = write_response("server-10", 43, 43); + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); let request = ClassificationWriteRequest { server_id: "server-10".into(), library_version: 42, @@ -2131,21 +2224,18 @@ mod tests { tag_type: Some(1), }], }; - let state = transport(base).patch_item(&request).unwrap(); + let state = transport(base).write_item(&request).unwrap(); assert_eq!(state.library_version, 43); + assert_eq!(state.item_version, 43); let requests = server.join().unwrap(); - assert!(requests[0].starts_with("PATCH /api/users/0/items/ABCD2345 HTTP/1.1\r\n")); + assert!(requests[0].starts_with("POST /api/users/0/items HTTP/1.1\r\n")); assert!(requests[0].contains("zotero-api-key: top-secret-key\r\n")); assert!(requests[0].contains("zotero-server-id: server-10\r\n")); - assert!(requests[0].contains("if-unmodified-since-version: 7\r\n")); + assert!(requests[0].contains("if-unmodified-since-version: 42\r\n")); assert!(requests[0].contains("content-type: application/json\r\n")); assert!( - requests[0] - .ends_with(r#"{"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}"#) - ); - assert!( - requests[1].starts_with( - "GET /api/users/0/items/ABCD2345?format=json&include=data HTTP/1.1\r\n" + requests[0].ends_with( + r#"[{"key":"ABCD2345","version":7,"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}]"# ) ); } @@ -2164,33 +2254,33 @@ mod tests { tags: vec![], }; assert_eq!( - transport(base).patch_item(&request).unwrap_err(), + transport(base).write_item(&request).unwrap_err(), ZoteroTransportError::RequestFailed ); - assert!(server.join().unwrap()[0].starts_with("PATCH ")); + assert!(server.join().unwrap()[0].starts_with("POST ")); let mut mismatched_request = request.clone(); mismatched_request.server_id = "other-server".into(); assert_eq!( Zotero10LocalAdapter::new("secret", "server-10") .unwrap() - .patch_item(&mismatched_request) + .write_item(&mismatched_request) .unwrap_err(), ZoteroTransportError::ServerMismatch ); - let unexpected_success = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let unexpected_success = "HTTP/1.1 204 No Content\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; let (base, server) = serve(vec![unexpected_success]); assert_eq!( - transport(base).patch_item(&request).unwrap_err(), + transport(base).write_item(&request).unwrap_err(), ZoteroTransportError::RequestFailed ); server.join().unwrap(); - let wrong_server = "HTTP/1.1 204 No Content\r\nZotero-Server-ID: other-server\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - let (base, server) = serve(vec![wrong_server]); + let wrong_server = write_response("other-server", 43, 43); + let (base, server) = serve(vec![Box::leak(wrong_server.into_boxed_str())]); assert_eq!( - transport(base).patch_item(&request).unwrap_err(), + transport(base).write_item(&request).unwrap_err(), ZoteroTransportError::ServerMismatch ); server.join().unwrap(); @@ -2198,7 +2288,7 @@ mod tests { for response in [ "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" .to_owned(), - item_response("other-server", 42, 7), + library_response("other-server", 42), ] { let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); let error = transport(base).get_item("ABCD2345").unwrap_err(); @@ -2229,7 +2319,8 @@ mod tests { } let malformed = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{"; - let (base, server) = serve(vec![malformed]); + let before = library_response("server-10", 42); + let (base, server) = serve(vec![Box::leak(before.into_boxed_str()), malformed]); assert_eq!( transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse @@ -2238,10 +2329,28 @@ mod tests { let wrong_key_body = r#"{"key":"BCDE3456","version":7,"data":{"itemType":"book"}}"#; let wrong_key_response = format!( - "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{wrong_key_body}", + "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{wrong_key_body}", wrong_key_body.len() ); - let (base, server) = serve(vec![Box::leak(wrong_key_response.into_boxed_str())]); + let before = library_response("server-10", 42); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(wrong_key_response.into_boxed_str()), + ]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); + server.join().unwrap(); + + let before = library_response("server-10", 42); + let item = item_response("server-10", 7); + let after = library_response("server-10", 43); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + Box::leak(after.into_boxed_str()), + ]); assert_eq!( transport(base).get_item("ABCD2345").unwrap_err(), ZoteroTransportError::InvalidResponse diff --git a/docs/PRD.md b/docs/PRD.md index 974b00f4..a57a46d9 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -64,7 +64,7 @@ Reviewed collection and tag changes default to a local dry-run plan. Each operat For execute-mode plans, the runtime must preflight every item before the first write, 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. -The Zotero 10+ adapter accepts a caller-owned API key and server identity only at runtime. It reads and conditionally patches one official Zotero item key at a time on the fixed loopback Local API, replaces complete collection and typed-tag arrays, and returns a fresh post-write item state. Credentials are neither serializable nor printable. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 write and rollback requirement. +The Zotero 10+ adapter accepts a caller-owned API key and server identity only at runtime. It brackets each item read with library-wide version reads and rejects drift. It conditionally writes one official Zotero item key at a time through the fixed loopback Local API, atomically replacing complete collection and typed-tag arrays under both library and item version preconditions. Credentials are neither serializable nor printable. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 write and rollback requirement. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index 065a4f51..5140f806 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -70,4 +70,4 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. 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 Zotero 10+ transport is pinned to `http://127.0.0.1:23119/api/users/0/items`, rejects redirects, uses finite timeouts and a 1 MiB single-item response limit, and accepts only official eight-character object keys. The caller supplies nonblank API key and server ID values; the adapter is neither debug-printable nor serializable. Reads send the server partition and API version. Writes send the API key, server partition, API version, content type, and item-version `If-Unmodified-Since-Version`, PATCH only complete `collections` and `tags` arrays, require `204 No Content`, and perform a fresh bounded GET. Errors expose static categories only. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 write, partial-failure, or rollback has been performed. +The Zotero 10+ transport is pinned to `http://127.0.0.1:23119/api/users/0/items`, rejects redirects, uses finite timeouts and a 1 MiB response limit, and accepts only official eight-character object keys. The caller supplies nonblank API key and server ID values; the adapter is neither debug-printable nor serializable. Each item GET is bracketed by bounded `format=versions` collection reads; all three responses must come from the expected server, the item response header must match the JSON object version, and unchanged library headers prove a stable read boundary. Writes POST a one-item array to the collection endpoint with the API key, server partition, API version, content type, library-version `If-Unmodified-Since-Version`, item key/version, and complete `collections` and `tags` arrays. A successful bounded `200 OK` response must identify only the requested item at index zero, advance its version, preserve the exact arrays, and report the new library version matching the item version. Errors expose static categories only. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 840a7313..bcef1e8b 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,7 +14,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. It holds caller-supplied credentials in a non-debuggable, non-serializable value, validates official object keys, disables redirects, bounds response reads, sends the server identity on reads and writes, and sends the API key only on writes. A PATCH replaces the complete collection and typed-tag arrays under the current item-version precondition, accepts only the documented no-content success, and returns a fresh GET state. Static error categories cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. It holds caller-supplied credentials in a non-debuggable, non-serializable value, validates official object keys, disables redirects, bounds response reads, sends the server identity on reads and writes, and sends the API key only on writes. Item reads are bracketed by library-wide version reads so the returned object and library coordinates describe one stable boundary. A one-item collection POST carries the reviewed library version in `If-Unmodified-Since-Version` and the reviewed item version in its body, replaces complete collection and typed-tag arrays, and accepts only a bounded success response that proves the exact resulting state and advanced version. Static error categories cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. ## Consequences diff --git a/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md b/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md index a30f66a3..dd158bd3 100644 --- a/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md +++ b/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md @@ -101,4 +101,4 @@ The following canonical Consensus records were fetched before recording the corr ## Research intake evidence -The Zotero classifier records item and library revisions plus the exact rule revision for each proposal. Keyword evidence is routing evidence only: unmatched records abstain, duplicate identities remain candidates, and neither path creates authoritative ontology knowledge. The official Zotero Local API and Write Requests documentation grounds server partitioning, runtime write authorization, item-version preconditions, official key syntax, and complete-array PATCH semantics. Mock transport fixtures exercise those contracts, while live Zotero 10 write and rollback evidence remains incomplete. Any model-assisted successor must add a `contextual-orchestrator` receipt while preserving the deterministic inputs and steward decision separately. +The Zotero classifier records item and library revisions plus the exact rule revision for each proposal. Keyword evidence is routing evidence only: unmatched records abstain, duplicate identities remain candidates, and neither path creates authoritative ontology knowledge. The official Zotero Local API and Write Requests documentation grounds server partitioning, runtime write authorization, separate library/object version semantics, official key syntax, and one-item collection POST semantics for atomic preconditions. Mock transport fixtures exercise those contracts, while live Zotero 10 write and rollback evidence remains incomplete. Any model-assisted successor must add a `contextual-orchestrator` receipt while preserving the deterministic inputs and steward decision separately. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1c9091db..7d40f607 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. A fixed-loopback Zotero 10 adapter now supplies authenticated, server-pinned, item-version-conditional complete collection/tag replacement with bounded post-write verification; mock TCP fixtures verify its exact wire contract and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. +The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback coordinates for every item whose applied state is proven. Unprovable current-item state is reported as indeterminate instead of falsely reversible. A fixed-loopback Zotero 10 adapter now supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; mock TCP fixtures verify its exact wire contract and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, or write claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. 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 01c7fa5bc7edac2f2b6ad47030062ffab7ebb956 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:45:16 +0900 Subject: [PATCH 04/12] test(zotero): close transport coverage gaps --- crates/conceptweave-zotero/src/lib.rs | 184 ++++++++++++++++++++++---- 1 file changed, 156 insertions(+), 28 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index f60371c1..eec4bd3f 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -519,13 +519,13 @@ impl Zotero10LocalAdapter { collections: &'a [String], tags: &'a [ItemTag], } - let body = serde_json::to_string(&[Write { + let body = serde_json::json!([Write { key: &request.item_key, version: request.item_version, collections: &request.collection_keys, tags: &request.tags, }]) - .map_err(|_| ZoteroTransportError::InvalidResponse)?; + .to_string(); let mut response = self .agent .post(&self.base) @@ -2115,20 +2115,7 @@ mod tests { .map(|response| { let (mut stream, _) = listener.accept().unwrap(); let mut bytes = vec![0; 16 * 1024]; - let mut length = 0; - loop { - length += stream.read(&mut bytes[length..]).unwrap(); - let request = String::from_utf8_lossy(&bytes[..length]); - let headers_end = request.find("\r\n\r\n").unwrap_or(usize::MAX); - let content_length = request - .lines() - .find_map(|line| line.strip_prefix("content-length: ")) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - if headers_end != usize::MAX && length >= headers_end + 4 + content_length { - break; - } - } + let length = stream.read(&mut bytes).unwrap(); stream.write_all(response.as_bytes()).unwrap(); String::from_utf8(bytes[..length].to_vec()).unwrap() }) @@ -2164,10 +2151,46 @@ mod tests { ) } + fn raw_response(server_id: Option<&str>, version: Option, body: &str) -> String { + let server = server_id + .map(|value| format!("Zotero-Server-ID: {value}\r\n")) + .unwrap_or_default(); + let version = version + .map(|value| format!("Last-Modified-Version: {value}\r\n")) + .unwrap_or_default(); + format!( + "HTTP/1.1 200 OK\r\n{server}{version}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + fn write_request() -> ClassificationWriteRequest { + ClassificationWriteRequest { + server_id: "server-10".into(), + library_version: 42, + item_key: "ABCD2345".into(), + item_version: 7, + collection_keys: vec!["BCDE3456".into()], + tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + } + } + fn transport(base: String) -> Zotero10LocalAdapter { Zotero10LocalAdapter::new_with_base("top-secret-key", "server-10", base).unwrap() } + fn assert_write_invalid(response: String) { + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!( + transport(base).write_item(&write_request()).unwrap_err(), + ZoteroTransportError::InvalidResponse + ); + server.join().unwrap(); + } + #[test] fn zotero10_get_uses_exact_item_route_and_server_partition() { let before = library_response("server-10", 42); @@ -2213,17 +2236,7 @@ mod tests { fn zotero10_post_atomically_replaces_complete_arrays() { let response = write_response("server-10", 43, 43); let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); - let request = ClassificationWriteRequest { - server_id: "server-10".into(), - library_version: 42, - item_key: "ABCD2345".into(), - item_version: 7, - collection_keys: vec!["BCDE3456".into()], - tags: vec![ItemTag { - tag: "kept".into(), - tag_type: Some(1), - }], - }; + let request = write_request(); let state = transport(base).write_item(&request).unwrap(); assert_eq!(state.library_version, 43); assert_eq!(state.item_version, 43); @@ -2235,7 +2248,7 @@ mod tests { assert!(requests[0].contains("content-type: application/json\r\n")); assert!( requests[0].ends_with( - r#"[{"key":"ABCD2345","version":7,"collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}]"# + r#"[{"collections":["BCDE3456"],"key":"ABCD2345","tags":[{"tag":"kept","type":1}],"version":7}]"# ) ); } @@ -2317,6 +2330,12 @@ mod tests { ZoteroTransportError::InvalidItemKey ); } + let mut invalid_write = write_request(); + invalid_write.item_key = "invalid".into(); + assert_eq!( + adapter.write_item(&invalid_write).unwrap_err(), + ZoteroTransportError::InvalidItemKey + ); let malformed = "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 42\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{"; let before = library_response("server-10", 42); @@ -2370,6 +2389,115 @@ mod tests { server.join().unwrap(); } + #[test] + fn zotero10_transport_covers_read_stage_failures() { + let library = library_response("server-10", 42); + let (base, server) = serve(vec![ + Box::leak(library.into_boxed_str()), + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::RequestFailed + ); + server.join().unwrap(); + + let before = library_response("server-10", 42); + let item = item_response("server-10", 7); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::RequestFailed + ); + server.join().unwrap(); + + for response in [ + raw_response(None, Some(42), "{}"), + raw_response(Some("server-10"), None, "{}"), + ] { + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); + server.join().unwrap(); + } + + let item_body = r#"{"key":"ABCD2345","version":8,"data":{"itemType":"book"}}"#; + for item in [ + raw_response(Some("other-server"), Some(8), item_body), + raw_response(Some("server-10"), None, item_body), + raw_response(Some("server-10"), Some(7), item_body), + ] { + let before = library_response("server-10", 42); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + ]); + let error = transport(base).get_item("ABCD2345").unwrap_err(); + assert!(matches!( + error, + ZoteroTransportError::InvalidResponse | ZoteroTransportError::ServerMismatch + )); + server.join().unwrap(); + } + + let oversized = "x".repeat((MAX_ITEM_RESPONSE_BYTES + 1) as usize); + let before = library_response("server-10", 42); + let item = raw_response(Some("server-10"), Some(7), &oversized); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + ]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::InvalidResponse + ); + server.join().unwrap(); + } + + #[test] + fn zotero10_transport_rejects_every_unproven_write_result() { + assert_write_invalid(raw_response(Some("server-10"), None, "{}")); + assert_write_invalid(raw_response(Some("server-10"), Some(43), "{")); + assert_write_invalid(raw_response(Some("server-10"), Some(43), "{}")); + assert_write_invalid(raw_response( + Some("server-10"), + Some(43), + r#"{"successful":{"0":{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}},"1":{"key":"BCDE3456","version":43,"data":{"itemType":"book"}}}}"#, + )); + for (version, body) in [ + ( + 43, + r#"{"successful":{"0":{"key":"BCDE3456","version":43,"data":{"itemType":"book","collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}}}}"#, + ), + ( + 7, + r#"{"successful":{"0":{"key":"ABCD2345","version":7,"data":{"itemType":"book","collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}}}}"#, + ), + ( + 43, + r#"{"successful":{"0":{"key":"ABCD2345","version":44,"data":{"itemType":"book","collections":["BCDE3456"],"tags":[{"tag":"kept","type":1}]}}}}"#, + ), + ( + 43, + r#"{"successful":{"0":{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":[],"tags":[{"tag":"kept","type":1}]}}}}"#, + ), + ( + 43, + r#"{"successful":{"0":{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["BCDE3456"],"tags":[]}}}}"#, + ), + ] { + assert_write_invalid(raw_response(Some("server-10"), Some(version), body)); + } + let oversized = "x".repeat((MAX_ITEM_RESPONSE_BYTES + 1) as usize); + assert_write_invalid(raw_response(Some("server-10"), Some(43), &oversized)); + } + #[test] fn zotero10_transport_never_formats_or_serializes_the_key() { let adapter = Zotero10LocalAdapter::new("top-secret-key", "server-10").unwrap(); From f83a63d4d1abb22afd4c294ae65239c4a6ef3af7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:12:39 +0900 Subject: [PATCH 05/12] test(research): reproduce authenticated proxy and inclusive response defects --- crates/conceptweave-zotero/src/lib.rs | 1 + .../src/tests/authenticated_transport.rs | 173 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 crates/conceptweave-zotero/src/tests/authenticated_transport.rs diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index fc79d989..9a5474fc 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2254,6 +2254,7 @@ fn normalize_title(value: &str) -> Option { #[cfg(test)] mod tests { + mod authenticated_transport; mod metadata_transport; use super::*; diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs new file mode 100644 index 00000000..d409b58c --- /dev/null +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -0,0 +1,173 @@ +use super::*; +use std::process::{Command, Stdio}; +use std::time::Instant; + +const PROXY_CHILD_CASE: &str = "CONCEPTWEAVE_AUTHENTICATED_PROXY_CASE"; +const SYNTHETIC_API_KEY: &str = "0123456789abcdef0123456789abcdef"; + +#[test] +fn authenticated_calls_never_use_environment_proxies() { + let mut failures = Vec::new(); + for request_kind in ["read", "write"] { + for proxy_variable in [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let proxy_url = format!("http://{}", listener.local_addr().unwrap()); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::authenticated_transport::authenticated_routing_child", + ]) + .env_clear() + .env(PROXY_CHILD_CASE, request_kind) + .env(proxy_variable, proxy_url) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + let mut proxy_connections = 0; + let status = loop { + match listener.accept() { + Ok((mut stream, _)) => { + proxy_connections += 1; + // Count connections without reading or retaining request credentials. + let _ = stream.write_all( + b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock), + } + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if started.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("isolated authenticated routing check timed out"); + } + thread::sleep(Duration::from_millis(5)); + }; + if proxy_connections != 0 || !status.success() { + failures.push(format!( + "{request_kind}/{proxy_variable}: proxy_connections={proxy_connections}, direct_success={}", + status.success() + )); + } + } + } + assert!(failures.is_empty(), "{failures:?}"); +} + +#[test] +fn authenticated_routing_child() { + let Ok(request_kind) = std::env::var(PROXY_CHILD_CASE) else { + return; + }; + let responses = match request_kind.as_str() { + "read" => vec![ + library_response("server-10", 42), + item_response("server-10", 7), + library_response("server-10", 42), + ], + "write" => vec![write_response("server-10", 43, 43)], + _ => panic!("unknown synthetic routing case"), + }; + let (base, server) = serve( + responses + .into_iter() + .map(|response| &*Box::leak(response.into_boxed_str())) + .collect(), + ); + let adapter = + Zotero10LocalAdapter::new_with_base(SYNTHETIC_API_KEY, "server-10", base).unwrap(); + let state = match request_kind.as_str() { + "read" => adapter.get_item("ABCD2345").unwrap(), + "write" => adapter.write_item(&write_request()).unwrap(), + _ => unreachable!(), + }; + assert_eq!(state.item_key, "ABCD2345"); + let requests = server.join().unwrap(); + for request in &requests { + assert!(request.contains("zotero-api-version: 3\r\n")); + assert!(request.contains("zotero-server-id: server-10\r\n")); + } + if request_kind == "read" { + assert_eq!(state.library_version, 42); + assert_eq!(requests.len(), 3); + assert!(requests[0].starts_with("GET /api/users/0/items?format=versions&limit=1 ")); + assert!( + requests[1].starts_with("GET /api/users/0/items/ABCD2345?format=json&include=data ") + ); + assert!(requests[2].starts_with("GET /api/users/0/items?format=versions&limit=1 ")); + assert!( + requests + .iter() + .all(|request| !request.contains("zotero-api-key:")) + ); + } else { + assert_eq!(state.library_version, 43); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /api/users/0/items HTTP/1.1\r\n")); + assert!(requests[0].contains(&format!("zotero-api-key: {SYNTHETIC_API_KEY}\r\n"))); + assert!(requests[0].contains("if-unmodified-since-version: 42\r\n")); + } +} + +fn padded_response(response: String, version: u64, byte_count: usize) -> &'static str { + let (_, original_body) = response.split_once("\r\n\r\n").unwrap(); + let body = format!( + "{original_body}{}", + " ".repeat(byte_count - original_body.len()) + ); + Box::leak(raw_response(Some("server-10"), Some(version), &body).into_boxed_str()) +} + +#[test] +fn item_and_library_reads_accept_exactly_the_byte_limit() { + let byte_count = MAX_ITEM_RESPONSE_BYTES as usize; + let (base, server) = serve(vec![ + padded_response(library_response("server-10", 42), 42, byte_count), + padded_response(item_response("server-10", 7), 7, byte_count), + padded_response(library_response("server-10", 42), 42, byte_count), + ]); + let state = transport(base) + .get_item("ABCD2345") + .expect("inclusive byte limit must accept each read-stage response"); + assert_eq!(state.library_version, 42); + assert_eq!(state.item_version, 7); + assert_eq!(server.join().unwrap().len(), 3); +} + +#[test] +fn authenticated_write_accepts_exactly_the_byte_limit() { + let (base, server) = serve(vec![padded_response( + write_response("server-10", 43, 43), + 43, + MAX_ITEM_RESPONSE_BYTES as usize, + )]); + let result = transport(base).write_item(&write_request()); + server.join().unwrap(); + let state = result.expect("inclusive byte limit must accept the write response"); + assert_eq!(state.library_version, 43); + assert_eq!(state.item_version, 43); +} + +#[test] +fn authenticated_write_rejects_one_byte_over_the_limit() { + let (base, server) = serve(vec![padded_response( + write_response("server-10", 43, 43), + 43, + MAX_ITEM_RESPONSE_BYTES as usize + 1, + )]); + let result = transport(base).write_item(&write_request()); + server.join().unwrap(); + assert_eq!(result.unwrap_err(), ZoteroTransportError::InvalidResponse); +} From 53bd1fe16c43adc5cb0e7a052183e80b8c6c2e25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:13:35 +0900 Subject: [PATCH 06/12] fix(research): isolate authenticated transport and reuse inclusive reader --- crates/conceptweave-zotero/src/lib.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 9a5474fc..6be38133 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -493,6 +493,7 @@ impl Zotero10LocalAdapter { return Err(ZoteroTransportError::InvalidCredentials); } let config = ureq::Agent::config_builder() + .proxy(None) .timeout_global(Some(Duration::from_secs(30))) .timeout_connect(Some(Duration::from_secs(2))) .timeout_recv_response(Some(Duration::from_secs(10))) @@ -663,11 +664,7 @@ fn version_header(headers: &ureq::http::HeaderMap) -> Result, ) -> Result { - response - .body_mut() - .with_config() - .limit(MAX_ITEM_RESPONSE_BYTES) - .read_to_string() + read_bounded_response_text(response, MAX_ITEM_RESPONSE_BYTES) .map_err(|_| ZoteroTransportError::InvalidResponse) } From b6b618b48e7f494f66d51ac6244be4ce6427bf2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:15:12 +0900 Subject: [PATCH 07/12] test(research): verify complete synthetic POST request framing --- .../src/tests/authenticated_transport.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index d409b58c..31fdf98c 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -154,7 +154,19 @@ fn authenticated_write_accepts_exactly_the_byte_limit() { MAX_ITEM_RESPONSE_BYTES as usize, )]); let result = transport(base).write_item(&write_request()); - server.join().unwrap(); + let requests = server.join().unwrap(); + let (headers, request_body) = requests[0].split_once("\r\n\r\n").unwrap(); + let declared_bytes: usize = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse() + .unwrap(); + assert_eq!( + request_body.len(), + declared_bytes, + "the synthetic server must consume the complete POST before closing" + ); let state = result.expect("inclusive byte limit must accept the write response"); assert_eq!(state.library_version, 43); assert_eq!(state.item_version, 43); From 7bcb791853ffa794529418ee9de1337fea4e1b15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:16:20 +0900 Subject: [PATCH 08/12] test(research): consume complete synthetic HTTP requests before replying --- crates/conceptweave-zotero/src/lib.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 6be38133..c9ce49f3 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2269,10 +2269,31 @@ mod tests { .into_iter() .map(|response| { let (mut stream, _) = listener.accept().unwrap(); - let mut bytes = vec![0; 16 * 1024]; - let length = stream.read(&mut bytes).unwrap(); + let mut bytes = Vec::new(); + loop { + let mut buffer = [0; 4096]; + let length = stream.read(&mut buffer).unwrap(); + assert_ne!(length, 0); + bytes.extend_from_slice(&buffer[..length]); + if let Some(header_end) = + bytes.windows(4).position(|part| part == b"\r\n\r\n") + { + let headers = std::str::from_utf8(&bytes[..header_end]).unwrap(); + let body_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .map(|value| value.parse::().unwrap()) + }) + .unwrap_or(0); + if bytes.len() >= header_end + 4 + body_length { + break; + } + } + } stream.write_all(response.as_bytes()).unwrap(); - String::from_utf8(bytes[..length].to_vec()).unwrap() + String::from_utf8(bytes).unwrap() }) .collect() }); From b388810be8bceb3a4f81c336708cf1c56a20d057 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 18:35:09 +0900 Subject: [PATCH 09/12] test(research): deterministically cover complete request framing --- .../src/tests/authenticated_transport.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index 31fdf98c..0d58ca79 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -5,6 +5,30 @@ use std::time::Instant; const PROXY_CHILD_CASE: &str = "CONCEPTWEAVE_AUTHENTICATED_PROXY_CASE"; const SYNTHETIC_API_KEY: &str = "0123456789abcdef0123456789abcdef"; +#[test] +fn synthetic_server_retains_headers_and_body_larger_than_its_read_buffer() { + let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![response]); + let address = base + .strip_prefix("http://") + .unwrap() + .split_once('/') + .unwrap() + .0; + let mut connection = std::net::TcpStream::connect(address).unwrap(); + // Both sections exceed the 4 KiB read buffer regardless of TCP packet timing. + let request = format!( + "POST /api/users/0/items HTTP/1.1\r\nHost: localhost\r\nX-Synthetic-Padding: {}\r\nContent-Length: 8192\r\n\r\n{}", + "h".repeat(8192), + "b".repeat(8192) + ); + connection.write_all(request.as_bytes()).unwrap(); + let mut received_response = String::new(); + connection.read_to_string(&mut received_response).unwrap(); + assert_eq!(received_response, response); + assert_eq!(server.join().unwrap(), [request]); +} + #[test] fn authenticated_calls_never_use_environment_proxies() { let mut failures = Vec::new(); From 97cce5a3c1ebd8087759250062c22e2fab397446 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:37:25 +0900 Subject: [PATCH 10/12] test(zotero): preserve uncertain submitted writes across authenticated HTTP --- .../src/tests/authenticated_transport.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index 0d58ca79..703cf19a 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -5,6 +5,98 @@ use std::time::Instant; const PROXY_CHILD_CASE: &str = "CONCEPTWEAVE_AUTHENTICATED_PROXY_CASE"; const SYNTHETIC_API_KEY: &str = "0123456789abcdef0123456789abcdef"; +#[test] +fn failed_http_write_with_matching_observation_remains_indeterminate() { + let report = classify_snapshot( + "10.0.1".into(), + Some("server-10".into()), + 42, + vec![item("ABCD2345", "book", "ontology learning", "", "")], + ); + let expected_request = write_request(); + let review = ReviewedClassificationWriteSet { + review_id: "synthetic-review".into(), + authority_receipt: "synthetic-authority".into(), + server_id: report.server_id.clone(), + zotero_version: report.zotero_version.clone(), + library_version: report.library_version, + rule_revision: report.rule_revision.into(), + snapshot_digest: report.snapshot_digest.clone(), + proposal_digest: classification_proposal_digest(&report), + snapshot_items: report.snapshot_items.clone(), + changes: vec![ReviewedClassificationChange { + item_key: expected_request.item_key.clone(), + item_version: expected_request.item_version, + reviewed_disposition: Disposition::Generation, + before_collection_keys: vec![], + before_tags: vec![], + after_collection_keys: expected_request.collection_keys.clone(), + after_tags: expected_request.tags.clone(), + }], + }; + let plan = + build_classification_write_plan(&report, &review, WriteMode::Execute, |set| set == &review) + .unwrap(); + let responses = vec![ + library_response("server-10", 42), + raw_response( + Some("server-10"), + Some(7), + r#"{"key":"ABCD2345","version":7,"data":{"itemType":"book"}}"#, + ), + library_response("server-10", 42), + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .into(), + library_response("server-10", 43), + item_response("server-10", 43), + library_response("server-10", 43), + ]; + let (base, server) = serve( + responses + .into_iter() + .map(|response| &*Box::leak(response.into_boxed_str())) + .collect(), + ); + let adapter = + Zotero10LocalAdapter::new_with_base(SYNTHETIC_API_KEY, "server-10", base).unwrap(); + let receipt = execute_classification_write_plan( + &plan, + |key| adapter.get_item(key), + |request| adapter.write_item(request), + ); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 7); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + let body: serde_json::Value = + serde_json::from_str(requests[3].split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!( + body[0]["collections"], + serde_json::json!(expected_request.collection_keys) + ); + assert_eq!( + body[0]["tags"], + serde_json::to_value(&expected_request.tags).unwrap() + ); + assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); + assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("ABCD2345")); + assert_eq!(receipt.indeterminate_request, Some(expected_request)); + assert_eq!(receipt.reconciliation_observation.unwrap().item_version, 43); + assert_eq!(receipt.proposal_digest, review.proposal_digest); + assert!(receipt.applied_item_keys.is_empty()); + assert!(receipt.rollback_operations.is_empty()); + assert!( + !serde_json::to_string(&plan) + .unwrap() + .contains(SYNTHETIC_API_KEY) + ); +} + #[test] fn synthetic_server_retains_headers_and_body_larger_than_its_read_buffer() { let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; From 29a37714b15c2fa93a820001046cc6c250781a40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:39:01 +0900 Subject: [PATCH 11/12] test(zotero): bind full matching observation in HTTP uncertainty regression --- .../src/tests/authenticated_transport.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index 703cf19a..f582afd0 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -85,8 +85,21 @@ fn failed_http_write_with_matching_observation_remains_indeterminate() { ); assert_eq!(receipt.outcome, ClassificationWriteOutcome::PartialFailure); assert_eq!(receipt.indeterminate_item_key.as_deref(), Some("ABCD2345")); - assert_eq!(receipt.indeterminate_request, Some(expected_request)); - assert_eq!(receipt.reconciliation_observation.unwrap().item_version, 43); + assert_eq!( + receipt.indeterminate_request, + Some(expected_request.clone()) + ); + assert_eq!( + receipt.reconciliation_observation, + Some(ClassificationItemState { + server_id: expected_request.server_id, + library_version: 43, + item_key: expected_request.item_key, + item_version: 43, + collection_keys: expected_request.collection_keys, + tags: expected_request.tags, + }) + ); assert_eq!(receipt.proposal_digest, review.proposal_digest); assert!(receipt.applied_item_keys.is_empty()); assert!(receipt.rollback_operations.is_empty()); From 06d836a07fdb434683f88a31b45150a8a06f27f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:40:42 +0900 Subject: [PATCH 12/12] docs(research): record authenticated transport uncertainty integration --- docs/TRD.md | 6 +++++ docs/adr/0007-reviewed-zotero-write-plan.md | 7 ++++++ docs/product-technical-gap-baseline.md | 28 +++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/TRD.md b/docs/TRD.md index 68d22707..b3e85652 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -60,6 +60,12 @@ Evaluation must separate extraction recall, semantic correctness, structural cor ## 11. Zotero research intake Execution receipts retain the verified proposal/source binding in every outcome. +The authenticated-transport regression composes the executor with an ephemeral +loopback HTTP fixture: a failed POST followed by a GET matching the requested +complete state still produces an indeterminate receipt, preserves the entire +submitted request and observed state, and produces no inferred inverse. Exactly +one POST is observed. This is synthetic wire-contract evidence, not an approved +live write, provider peer authentication or real-library recovery. 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 diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 9f0a1182..e6f52f42 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -56,6 +56,13 @@ adopt the required field without deriving fresh authority from serialized plans. ### Execution evidence correction (2026-09-06, Proposed) +PR #17 integration preserves the transport implementation while inheriting the +original-owner fix. Test `97cce5a`, strengthened by `29a3771`, composes authenticated +HTTP with the executor and verifies failed POST plus a fully matching observed +state remains unknown. Complete request/observation, proposal binding, single POST +and no inferred inverse are asserted. No new runtime, retry policy or authority +issuer is introduced; synthetic transport evidence does not prove live recovery. + 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a2274451..fe05daa1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,34 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #17 authenticated transport successor verification (2026-09-06) + +Baseline `c88f9a34c1fc4e72e38cf66b1d2f3fcb305e560a` passed 100 tests/19 suites. +Normal merge `a2768ae` retains that transport and parent `84b27fb`; documentation +conflicts retain the original wire contract while replacing unsafe post-read +completion/rollback inference. No original transport implementation is replaced. +Independent review found missing executor-plus-HTTP regression coverage, added +in `97cce5a` and strengthened in `29a3771` to compare the entire observed state. + +The synthetic fixture returns a failed POST followed by fully matching metadata. +Exactly one POST is observed; the receipt preserves the exact submitted request, +complete observation and proposal binding, remains indeterminate, and emits no +applied or inverse operations. It uses an ephemeral loopback port and synthetic +credentials only. Existing proxy, conditional-write and provider-response tests +remain intact. This does not prove peer authentication, live mutation or recovery. + +Final source passed 123 tests/19 result suites including three doctests, strict +Clippy, warnings-denied rustdoc, format/CI-contract/diff and unchanged coverage +gate: 226/226 functions, 2022/2022 normalized regions, 354/354 normalized branches. +Raw LLVM: 2508/2560 lines, 3799/3880 regions, 314/354 branches, not 100%. +Logs use `/tmp/conceptweave-pr17-scope-` with `baseline.log`, `complete.log`, +`clippy-complete.log`, `rustdoc.log` and `coverage-complete.log`. + +Protected merge, release, authentic decisions and real library reclassification +remain open. Next verified dependencies are PR #18 local authorization then PR #19 +approved execution; they must preserve scope and uncertainty without reissuing +authority from audit JSON. The root checkout still lacks this cascade. + ### PR #16 multilingual successor verification (2026-09-06) Baseline `044018cef4e5d3e919b278a1cfebe56857863601` passed 87 tests/19 suites.