diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index fadb8dcd..ec148b96 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -21,6 +21,7 @@ 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_SNAPSHOT_ELAPSED: Duration = Duration::from_secs(300); +const MAX_ITEM_RESPONSE_BYTES: u64 = 1024 * 1024; const LOCAL_API: &str = "http://127.0.0.1:23119/api/users/0/items"; #[cfg(test)] @@ -448,6 +449,243 @@ 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() + .proxy(None) + .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 before = self.library_version()?; + 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)?; + 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, + }) + } + + /// Atomically replaces one item's complete collection and tag arrays. + pub fn write_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 Write<'a> { + key: &'a str, + version: u64, + collections: &'a [String], + tags: &'a [ItemTag], + } + let body = serde_json::json!([Write { + key: &request.item_key, + version: request.item_version, + collections: &request.collection_keys, + tags: &request.tags, + }]) + .to_string(); + let mut response = self + .agent + .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.library_version.to_string(), + ) + .header("Content-Type", "application/json") + .send(body) + .map_err(|_| ZoteroTransportError::RequestFailed)?; + if response.status() != ureq::http::StatusCode::OK { + return Err(ZoteroTransportError::RequestFailed); + } + self.verify_server(response.headers())?; + let library_version = version_header(response.headers())?; + #[derive(Deserialize)] + struct WriteResponse { + successful: BTreeMap, + } + let body = bounded_body(&mut response)?; + let mut written: WriteResponse = + serde_json::from_str(&body).map_err(|_| ZoteroTransportError::InvalidResponse)?; + 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 { + 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 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 { + read_bounded_response_text(response, MAX_ITEM_RESPONSE_BYTES) + .map_err(|_| ZoteroTransportError::InvalidResponse) +} + +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 { @@ -2182,6 +2420,7 @@ fn normalize_title(value: &str) -> Option { #[cfg(test)] mod tests { + mod authenticated_transport; mod metadata_transport; use super::*; @@ -2191,6 +2430,429 @@ mod tests { 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::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).unwrap() + }) + .collect() + }); + (format!("http://{address}/api/users/0/items"), handle) + } + + 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() + ) + } + + 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); + 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); + 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[1].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?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_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 = write_request(); + 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("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: 42\r\n")); + assert!(requests[0].contains("content-type: application/json\r\n")); + assert!( + requests[0].ends_with( + r#"[{"collections":["BCDE3456"],"key":"ABCD2345","tags":[{"tag":"kept","type":1}],"version":7}]"# + ) + ); + } + + #[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).write_item(&request).unwrap_err(), + ZoteroTransportError::RequestFailed + ); + 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() + .write_item(&mismatched_request) + .unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + + 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).write_item(&request).unwrap_err(), + ZoteroTransportError::RequestFailed + ); + server.join().unwrap(); + + 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).write_item(&request).unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + server.join().unwrap(); + + for response in [ + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_owned(), + 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(); + 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").err().unwrap(), + ZoteroTransportError::InvalidCredentials + ); + assert_eq!( + 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 + ); + } + 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); + let (base, server) = serve(vec![Box::leak(before.into_boxed_str()), malformed]); + 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: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{wrong_key_body}", + wrong_key_body.len() + ); + 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 + ); + 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_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(); + 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(), 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..f582afd0 --- /dev/null +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -0,0 +1,314 @@ +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 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.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()); + 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"; + 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(); + 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()); + 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); +} + +#[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); +} diff --git a/docs/PRD.md b/docs/PRD.md index b7f04907..24ed0d30 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -80,6 +80,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 bound to the exact reviewed plan coordinates. Dry-run receipts enumerate every planned item as untouched. Execution receipts identify verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions, including an identity- and version-confirmed unexpected mutation. Cross-item atomicity is not claimed. +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 binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index b4f14615..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 @@ -101,4 +107,6 @@ Structural, source, proposal, and label checks precede the external verifier. Bl Provider deserialization captures each complete JSON object before projecting metadata. Snapshot hashing serializes the domain marker `conceptweave-zotero-snapshot-v2` followed by key-ordered pairs of that canonical source JSON and the actual typed classifier input. Unknown nested fields, array order, and omitted-versus-explicit default fields remain bound; changing a typed input after decoding also changes the digest. Synthetic offline typed items have no captured provider object and bind an explicit absent-source value alongside their typed input. Earlier reduced-content digests remain historical evidence and cannot establish this complete-content contract; regenerate the report and review artifacts and obtain fresh approval before any release or approved write. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. No authenticated Zotero 10+ HTTP mutation transport exists in this slice. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. + +The Zotero 10+ transport is pinned to `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 082e60ba..e6f52f42 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -18,6 +18,8 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. Dry-run enumerates every operation as not attempted. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid response, a follow-up read is observation only: matching before-state cannot prove a delayed request terminated, and matching after-state or a newer revision cannot prove which writer caused it. The receipt keeps the exact submitted request and optional observation, always names that item as indeterminate, and creates no inverse for that unconfirmed write. Earlier directly verified applied items and their inverse coordinates remain intact. The API key remains adapter-owned. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. 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 ### Source-scope amendment (2026-09-06, Proposed) @@ -48,12 +50,19 @@ adopt the required field without deriving fresh authority from serialized plans. - Review and rollback semantics can be tested on Zotero 9 without changing the library. - Exact before-state checks prevent silent loss of unrelated collections or automatic-tag metadata. -- AC5 is implemented 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 ### 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 @@ -75,4 +84,4 @@ list of previously verified inverse operations is empty. - Writing through Zotero 9 was rejected because the provider does not support it. - Storing only collection/tag deltas was rejected because Zotero array updates are complete replacements and cannot prove lossless rollback. -- Adding the HTTP writer now was rejected because no Zotero 10+ runtime or approved local key is available for end-to-end verification. +- 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 84c5e056..782f30d9 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..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. 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 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.