diff --git a/SECURITY.md b/SECURITY.md index 5d930ad0..548c11e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,6 +29,13 @@ All source artifacts, generated candidate payloads, external ontology files, mod 8. external-source SSRF or credential leakage; 9. model/provider compromise or unexpected retention; 10. governance bypass from Proposed/Validated directly to Published; -11. in-place mutation or overwrite of previously published semantic truth. +11. in-place mutation or overwrite of previously published semantic truth; +12. credential disclosure or endpoint interposition on provider-defined local transports that do not cryptographically authenticate or encrypt the peer channel. -Security findings become tests before the related runtime capability can be marked release-ready. +## Zotero Local API write-back boundary + +Zotero 10+ write authorization and mutation use the provider-defined loopback HTTP Local API. Loopback pinning, redirect rejection, and `Zotero-Server-ID` continuity checks do not encrypt `Zotero-API-Key` traffic and do not authenticate the local peer before the key is transmitted. `Zotero-Server-ID` is a database continuity/precondition coordinate, not cryptographic server authentication. + +A hostile same-host process capable of binding, observing, or interposing on the loopback endpoint therefore remains an unresolved credential-confidentiality threat. The currently documented Zotero Local API does not provide an HTTPS or OS-authenticated IPC write endpoint that ConceptWeave can substitute. Consequently, mock/local orchestration may be tested, but enterprise-secure live write-back remains fail closed. It may become release-eligible only if Zotero provides a protected transport or an explicit product-security/governance decision narrows the supported threat model and accepts the residual same-host risk. The detailed actor, asset, residual-risk, and release decision is maintained in `THREAT_MODEL.md`, and `docs/TRD.md` carries the same technical boundary. + +Security findings become tests before the related runtime capability can be marked release-ready. \ No newline at end of file diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 00000000..db08f607 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,69 @@ +# ConceptWeave Threat Model + +## Scope and authority + +This document records product security boundaries that affect ConceptWeave semantic engineering and release eligibility. `SECURITY.md` defines baseline controls; this file names concrete actors, assets, trust assumptions, residual risks, and fail-closed decisions for implemented capabilities. Product-domain truth remains with its canonical owner. External providers and CWL sibling products are treated through explicit ports/contracts rather than copied authority. + +## Protected assets + +- immutable source snapshots, source coordinates, digests, and observation receipts; +- semantic candidates, validation evidence, steward review receipts, and immutable semantic releases; +- Zotero bibliographic records and reviewed write plans; +- credentials, authorization tokens, API keys, and source-registry capability material; +- tenant/workspace authorization context when introduced; +- release provenance, SBOM/provenance evidence, and rollback coordinates. + +Credentials and authorization material must never become semantic evidence, serialized domain artifacts, logs, model prompts, test fixtures, or immutable release payloads. + +## Trust boundaries + +Source artifacts, imported ontologies, provider responses, model outputs, web-retrieved content, and external system metadata are untrusted until validated at their owning boundary. LLM proposals are non-authoritative until deterministic validation and steward publication. Source Observation accepts only bounded adapter evidence and must not turn a syntactically plausible identifier into authorization provenance. Client Consumption admits only released immutable contracts. + +## Primary threats + +1. malicious or malformed source content causing semantic poisoning, parser/resource exhaustion, or provenance confusion; +2. model output being promoted to authority without deterministic validation and steward review; +3. credential or source-authorization leakage across domain, evidence, log, or model boundaries; +4. source-system mutation from discovery/validation code or hidden cross-service SQL coupling; +5. stale, ambiguous, or mismatched source coordinates being recorded as immutable evidence; +6. in-place mutation of published semantic truth instead of explicit supersession; +7. tenant/workspace evidence disclosure; +8. SSRF, DNS rebinding, unsafe redirects, or unbounded external retrieval; +9. dependency/provider compromise or unexpected retention; +10. write-back without reviewed before/after/rollback evidence and exact preconditions. + +## Zotero 10+ Local API transport boundary + +Zotero's documented Local API endpoint is `http://localhost:23119/api/`. Read requests are unauthenticated. Write requests require a user-granted local API key and, in Zotero 10+, the expected `Zotero-Server-ID` continuity coordinate. + +`Zotero-Server-ID is not cryptographic server authentication`. It identifies the Zotero database instance and supports stale/database-switch detection, but it does not authenticate the loopback peer before a request transmits `Zotero-API-Key`. Loopback pinning and redirect rejection reduce network exposure but do not encrypt HTTP traffic or provide OS-authenticated IPC. + +### Threat actor + +A hostile same-host process that can bind, observe, or interpose on the loopback endpoint is inside the unresolved threat boundary for Zotero write credentials. ConceptWeave currently has no provider-documented HTTPS or equivalent OS-authenticated IPC endpoint that can replace the Local API write path. + +### Current decision + +The Zotero adapter may be used for read-only intake and for mock/local verification of authorization and write orchestration. It must not be represented or released as enterprise-secure live write-back while confidentiality against a hostile same-host process is unproven. Live enterprise write-back therefore remains fail closed. + +A future release may cross this boundary only when one of the following is true: + +- Zotero exposes an authenticated encrypted or OS-authenticated IPC transport and ConceptWeave verifies it before transmitting a key; or +- a product-security decision explicitly narrows the supported threat model to exclude hostile same-host observation/interposition, records the residual credential risk, and receives the required governance approval. + +Neither path may reinterpret `Zotero-Server-ID` as cryptographic peer authentication. + +## Zotero write invariants retained regardless of transport decision + +- local API keys stay private and non-serializable; +- authorization is user initiated and denial/rate-limit outcomes remain fail closed; +- server/library/item preconditions are verified before mutation; +- database switches are surfaced as a distinct failure; +- dry-run performs no mutation; +- approved writes preserve exact before/after evidence, partial-failure reconciliation, and rollback coordinates; +- attachments and bibliographic source records are not deleted by classification write-back; +- descendant integration evidence never back-proves an unresolved predecessor contract. + +## Release gate + +A capability is not release-ready while a valid security finding lacks a deterministic test or equivalent machine-verifiable contract, while required exact-head checks are non-terminal, or while the implemented transport cannot satisfy the advertised security claim. Documentation must describe residual risk without upgrading provider guarantees by inference. diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index ec148b96..2be7e611 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -22,7 +22,11 @@ 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 MAX_AUTH_RESPONSE_BYTES: u64 = 512; +const MAX_AUTH_APP_NAME_BYTES: usize = 128; +const MAX_RETRY_AFTER_SECONDS: u64 = 86_400; const LOCAL_API: &str = "http://127.0.0.1:23119/api/users/0/items"; +const LOCAL_API_ROOT: &str = "http://127.0.0.1:23119"; #[cfg(test)] static TEST_LOCAL_API: std::sync::Mutex> = std::sync::Mutex::new(None); @@ -454,6 +458,19 @@ pub struct ClassificationItemState { pub enum ZoteroTransportError { /// The caller did not provide a usable API key and server identity. InvalidCredentials, + /// Zotero did not recognize the caller's authorization request. + Unauthorized, + /// The user denied the one-time authorization request. + Denied, + /// Zotero rate-limited authorization; a safe delta may be exposed. + RateLimited { + /// Retry delay in seconds when it was a bounded integer delta. + retry_after_seconds: Option, + }, + /// A write key is no longer authorized and must be replaced by the caller. + ReauthorizationRequired, + /// A write precondition no longer matches the Zotero library. + StalePrecondition, /// The item key is not an official eight-character Zotero object key. InvalidItemKey, /// The Local API rejected the request or could not be reached. @@ -464,6 +481,108 @@ pub enum ZoteroTransportError { InvalidResponse, } +/// One user-approved Zotero 10 Local API authorization. +/// +/// The key remains private and this type deliberately implements neither +/// [`Debug`] nor [`Serialize`]. Authorization performs exactly one request; +/// callers decide whether and when another user prompt is appropriate. +pub struct Zotero10LocalAuthorization { + api_key: String, + server_id: String, + remembered: bool, +} + +impl Zotero10LocalAuthorization { + /// Requests authorization from Zotero's fixed loopback endpoint once. + pub fn request( + app_name: &str, + server_id: impl Into, + ) -> Result { + Self::request_from(app_name, server_id.into(), LOCAL_API_ROOT) + } + + #[cfg(test)] + fn request_with_base( + app_name: &str, + server_id: impl Into, + base: String, + ) -> Result { + Self::request_from(app_name, server_id.into(), &base) + } + + fn request_from( + app_name: &str, + server_id: String, + base: &str, + ) -> Result { + if app_name.trim().is_empty() + || app_name.len() > MAX_AUTH_APP_NAME_BYTES + || server_id.trim().is_empty() + { + return Err(ZoteroTransportError::InvalidCredentials); + } + let url = format!("{base}/api/local/authorize"); + let mut response = local_agent() + .post(&url) + .header("Content-Type", "application/json") + .header("Zotero-Server-ID", &server_id) + .send(serde_json::json!({ "appName": app_name }).to_string()) + .map_err(|_| ZoteroTransportError::RequestFailed)?; + verify_server_id(response.headers(), &server_id)?; + match response.status().as_u16() { + 401 => return Err(ZoteroTransportError::Unauthorized), + 403 => { + #[derive(Deserialize)] + struct DenialResponse { + denied: bool, + } + let body = bounded_body_with_limit(&mut response, MAX_AUTH_RESPONSE_BYTES)?; + let denial: DenialResponse = serde_json::from_str(&body) + .map_err(|_| ZoteroTransportError::InvalidResponse)?; + return if denial.denied { + Err(ZoteroTransportError::Denied) + } else { + Err(ZoteroTransportError::InvalidResponse) + }; + } + 429 => { + return Err(ZoteroTransportError::RateLimited { + retry_after_seconds: retry_after_seconds(response.headers()), + }); + } + 200 => {} + _ => return Err(ZoteroTransportError::RequestFailed), + } + #[derive(Deserialize)] + struct AuthorizationResponse { + key: String, + remember: bool, + } + let body = bounded_body_with_limit(&mut response, MAX_AUTH_RESPONSE_BYTES)?; + let authorization: AuthorizationResponse = + serde_json::from_str(&body).map_err(|_| ZoteroTransportError::InvalidResponse)?; + if !is_valid_local_api_key(&authorization.key) { + return Err(ZoteroTransportError::InvalidResponse); + } + Ok(Self { + api_key: authorization.key, + server_id, + remembered: authorization.remember, + }) + } + + /// Reports whether Zotero agreed to remember this authorization. + pub const fn remembered(&self) -> bool { + self.remembered + } + + /// Consumes the authorization and creates the existing write adapter. + pub fn into_adapter(self) -> Zotero10LocalAdapter { + Zotero10LocalAdapter::build(self.api_key, self.server_id, LOCAL_API.to_owned()) + .expect("validated authorization always builds an adapter") + } +} + /// Minimal authenticated adapter for Zotero 10+ Local API item metadata writes. /// /// Credentials remain private and this type deliberately implements neither @@ -498,22 +617,14 @@ impl Zotero10LocalAdapter { server_id: String, base: String, ) -> Result { - if api_key.trim().is_empty() || server_id.trim().is_empty() { + if !is_valid_local_api_key(&api_key) || 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), + agent: local_agent(), }) } @@ -532,6 +643,10 @@ impl Zotero10LocalAdapter { .header("Zotero-Server-ID", &self.server_id) .call() .map_err(|_| ZoteroTransportError::RequestFailed)?; + self.verify_server(response.headers())?; + if response.status() != ureq::http::StatusCode::OK { + return Err(ZoteroTransportError::RequestFailed); + } let item = self.read_item(response, item_key)?; let after = self.library_version()?; if before != after { @@ -583,10 +698,15 @@ impl Zotero10LocalAdapter { .header("Content-Type", "application/json") .send(body) .map_err(|_| ZoteroTransportError::RequestFailed)?; + self.verify_server(response.headers())?; + match response.status().as_u16() { + 401 => return Err(ZoteroTransportError::ReauthorizationRequired), + 412 => return Err(ZoteroTransportError::StalePrecondition), + _ => {} + } 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 { @@ -628,6 +748,9 @@ impl Zotero10LocalAdapter { .call() .map_err(|_| ZoteroTransportError::RequestFailed)?; self.verify_server(response.headers())?; + if response.status() != ureq::http::StatusCode::OK { + return Err(ZoteroTransportError::RequestFailed); + } let version = version_header(response.headers())?; bounded_body(&mut response)?; Ok(version) @@ -638,7 +761,6 @@ impl Zotero10LocalAdapter { 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 = @@ -650,18 +772,50 @@ impl Zotero10LocalAdapter { } 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) - } + verify_server_id(headers, &self.server_id) + } +} + +fn local_agent() -> ureq::Agent { + 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))) + .http_status_as_error(false) + .max_redirects(0) + .build(); + ureq::Agent::new_with_config(config) +} + +fn verify_server_id( + headers: &ureq::http::HeaderMap, + expected: &str, +) -> Result<(), ZoteroTransportError> { + let server = headers + .get("Zotero-Server-ID") + .and_then(|value| value.to_str().ok()) + .ok_or(ZoteroTransportError::InvalidResponse)?; + if server == expected { + Ok(()) + } else { + Err(ZoteroTransportError::ServerMismatch) } } +fn retry_after_seconds(headers: &ureq::http::HeaderMap) -> Option { + headers + .get("Retry-After") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .filter(|seconds| *seconds <= MAX_RETRY_AFTER_SECONDS) +} + +fn is_valid_local_api_key(api_key: &str) -> bool { + api_key.len() == 32 && api_key.bytes().all(|byte| byte.is_ascii_graphic()) +} + fn version_header(headers: &ureq::http::HeaderMap) -> Result { headers .get("Last-Modified-Version") @@ -673,8 +827,14 @@ fn version_header(headers: &ureq::http::HeaderMap) -> Result, ) -> Result { - read_bounded_response_text(response, MAX_ITEM_RESPONSE_BYTES) - .map_err(|_| ZoteroTransportError::InvalidResponse) + bounded_body_with_limit(response, MAX_ITEM_RESPONSE_BYTES) +} + +fn bounded_body_with_limit( + response: &mut ureq::http::Response, + limit: u64, +) -> Result { + read_bounded_response_text(response, limit).map_err(|_| ZoteroTransportError::InvalidResponse) } fn validate_item_key(item_key: &str) -> Result<(), ZoteroTransportError> { @@ -2509,6 +2669,16 @@ mod tests { ) } + fn authorize_response(status: &str, server_id: Option<&str>, body: &str) -> String { + let server = server_id + .map(|value| format!("Zotero-Server-ID: {value}\r\n")) + .unwrap_or_default(); + format!( + "HTTP/1.1 {status}\r\n{server}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + fn write_request() -> ClassificationWriteRequest { ClassificationWriteRequest { server_id: "server-10".into(), @@ -2524,7 +2694,247 @@ mod tests { } fn transport(base: String) -> Zotero10LocalAdapter { - Zotero10LocalAdapter::new_with_base("top-secret-key", "server-10", base).unwrap() + Zotero10LocalAdapter::new_with_base("0123456789abcdef0123456789abcdef", "server-10", base) + .unwrap() + } + + fn authorization_error( + result: Result, + ) -> ZoteroTransportError { + match result { + Ok(_) => panic!("authorization unexpectedly succeeded"), + Err(error) => error, + } + } + + #[test] + fn zotero10_authorization_uses_exact_wire_contract_and_builds_adapter() { + let body = r#"{"key":"0123456789abcdef0123456789abcdef","remember":true}"#; + let response = authorize_response("200 OK", Some("server-10"), body); + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let authorize_base = items_base.replace("/api/users/0/items", ""); + + let authorization = Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + authorize_base, + ) + .unwrap(); + assert!(authorization.remembered()); + let _adapter = authorization.into_adapter(); + + let request = &server.join().unwrap()[0]; + assert!(request.starts_with("POST /api/local/authorize HTTP/1.1\r\n")); + assert!(request.contains("content-type: application/json\r\n")); + assert!(request.contains("zotero-server-id: server-10\r\n")); + assert!(request.ends_with(r#"{"appName":"ConceptWeave"}"#)); + assert!(!request.contains("0123456789abcdef0123456789abcdef")); + + let body = r#"{"key":"fedcba9876543210fedcba9876543210","remember":false}"#; + let response = authorize_response("200 OK", Some("server-10"), body); + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let authorization = Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.replace("/api/users/0/items", ""), + ) + .unwrap(); + assert!(!authorization.remembered()); + server.join().unwrap(); + } + + #[test] + fn zotero10_authorization_rejects_invalid_input_and_unproven_success() { + let oversized_name = "x".repeat(MAX_AUTH_APP_NAME_BYTES + 1); + for app_name in ["", " ", oversized_name.as_str()] { + assert_eq!( + authorization_error(Zotero10LocalAuthorization::request(app_name, "server-10")), + ZoteroTransportError::InvalidCredentials + ); + } + assert_eq!( + authorization_error(Zotero10LocalAuthorization::request("ConceptWeave", " ")), + ZoteroTransportError::InvalidCredentials + ); + + for response in [ + authorize_response( + "200 OK", + None, + r#"{"key":"0123456789abcdef0123456789abcdef","remember":false}"#, + ), + authorize_response( + "200 OK", + Some("other-server"), + r#"{"key":"0123456789abcdef0123456789abcdef","remember":false}"#, + ), + authorize_response( + "200 OK", + Some("server-10"), + r#"{"key":"too-short","remember":false}"#, + ), + authorize_response( + "200 OK", + Some("server-10"), + r#"{"key":"0123456789abcdef0123456789abcde ","remember":false}"#, + ), + authorize_response("200 OK", Some("server-10"), "{"), + authorize_response( + "200 OK", + Some("server-10"), + &format!(r#"{{"key":"{}","remember":false}}"#, "x".repeat(1024)), + ), + ] { + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let error = authorization_error(Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.replace("/api/users/0/items", ""), + )); + assert!(matches!( + error, + ZoteroTransportError::InvalidResponse | ZoteroTransportError::ServerMismatch + )); + server.join().unwrap(); + } + + for (response, expected) in [ + ( + "HTTP/1.1 500 Internal Server Error\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ZoteroTransportError::RequestFailed, + ), + ( + "HTTP/1.1 429 Too Many Requests\r\nZotero-Server-ID: server-10\r\nRetry-After: tomorrow\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ZoteroTransportError::RateLimited { + retry_after_seconds: None, + }, + ), + ] { + let (items_base, server) = serve(vec![response]); + assert_eq!( + authorization_error(Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.replace("/api/users/0/items", ""), + )), + expected + ); + server.join().unwrap(); + } + + assert_eq!( + authorization_error(Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + "http://127.0.0.1:0".into(), + )), + ZoteroTransportError::RequestFailed + ); + } + + #[test] + fn zotero10_authorization_denial_and_rate_limit_are_single_attempt_errors() { + for (response, expected) in [ + ( + authorize_response("403 Forbidden", Some("server-10"), r#"{"denied":true}"#), + ZoteroTransportError::Denied, + ), + ( + authorize_response("401 Unauthorized", Some("server-10"), ""), + ZoteroTransportError::Unauthorized, + ), + ( + "HTTP/1.1 429 Too Many Requests\r\nZotero-Server-ID: server-10\r\nRetry-After: 17\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), + ZoteroTransportError::RateLimited { + retry_after_seconds: Some(17), + }, + ), + ( + "HTTP/1.1 429 Too Many Requests\r\nZotero-Server-ID: server-10\r\nRetry-After: 999999999999\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned(), + ZoteroTransportError::RateLimited { + retry_after_seconds: None, + }, + ), + ] { + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!( + authorization_error(Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.replace("/api/users/0/items", ""), + )), + expected + ); + assert_eq!(server.join().unwrap().len(), 1); + } + + for response in [ + authorize_response("403 Forbidden", Some("server-10"), ""), + authorize_response("403 Forbidden", Some("server-10"), r#"{"denied":false}"#), + authorize_response("403 Forbidden", None, r#"{"denied":true}"#), + authorize_response( + "403 Forbidden", + Some("server-10"), + &"x".repeat((MAX_AUTH_RESPONSE_BYTES + 1) as usize), + ), + ] { + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert!(matches!( + authorization_error(Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.replace("/api/users/0/items", ""), + )), + ZoteroTransportError::InvalidResponse + )); + assert_eq!(server.join().unwrap().len(), 1); + } + } + + #[test] + fn zotero10_write_names_reauthorization_and_stale_precondition() { + for (status, expected) in [ + ( + "401 Unauthorized", + ZoteroTransportError::ReauthorizationRequired, + ), + ( + "412 Precondition Failed", + ZoteroTransportError::StalePrecondition, + ), + ] { + let response = format!( + "HTTP/1.1 {status}\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!( + transport(base).write_item(&write_request()).unwrap_err(), + expected + ); + assert_eq!(server.join().unwrap().len(), 1); + } + + assert_eq!( + transport("http://127.0.0.1:0/api/users/0/items".into()) + .write_item(&write_request()) + .unwrap_err(), + ZoteroTransportError::RequestFailed + ); + + let response = library_response("server-10", 42); + let (base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::RequestFailed + ); + server.join().unwrap(); + + assert_eq!( + transport("http://127.0.0.1:0/api/users/0/items".into()) + .get_item("ABCD2345") + .unwrap_err(), + ZoteroTransportError::RequestFailed + ); } fn assert_write_invalid(response: String) { @@ -2573,7 +2983,7 @@ mod tests { assert!( requests .iter() - .all(|request| !request.contains("top-secret-key")) + .all(|request| !request.contains("0123456789abcdef0123456789abcdef")) ); } @@ -2587,7 +2997,7 @@ mod tests { 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-api-key: 0123456789abcdef0123456789abcdef\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")); @@ -2600,8 +3010,7 @@ mod tests { #[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 stale = "HTTP/1.1 412 Precondition Failed\r\nZotero-Server-ID: server-10\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(), @@ -2613,14 +3022,22 @@ mod tests { }; assert_eq!( transport(base).write_item(&request).unwrap_err(), - ZoteroTransportError::RequestFailed + ZoteroTransportError::StalePrecondition ); assert!(server.join().unwrap()[0].starts_with("POST ")); + let switched = "HTTP/1.1 412 Precondition Failed\r\nZotero-Server-ID: other-server\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![switched]); + assert_eq!( + transport(base).write_item(&request).unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + server.join().unwrap(); + let mut mismatched_request = request.clone(); mismatched_request.server_id = "other-server".into(); assert_eq!( - Zotero10LocalAdapter::new("secret", "server-10") + Zotero10LocalAdapter::new("0123456789abcdef0123456789abcdef", "server-10") .unwrap() .write_item(&mismatched_request) .unwrap_err(), @@ -2644,8 +3061,7 @@ mod tests { 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(), + "HTTP/1.1 500 Internal Server Error\r\nZotero-Server-ID: server-10\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())]); @@ -2665,10 +3081,25 @@ mod tests { ZoteroTransportError::InvalidCredentials ); assert_eq!( - Zotero10LocalAdapter::new("secret", " ").err().unwrap(), + Zotero10LocalAdapter::new("0123456789abcdef0123456789abcdef", " ") + .err() + .unwrap(), ZoteroTransportError::InvalidCredentials ); - let adapter = Zotero10LocalAdapter::new("secret", "server-10").unwrap(); + for api_key in [ + "too-short", + "0123456789abcdef0123456789abcde ", + "0123456789abcdef0123456789abcdefx", + ] { + assert_eq!( + Zotero10LocalAdapter::new(api_key, "server-10") + .err() + .unwrap(), + ZoteroTransportError::InvalidCredentials + ); + } + let adapter = + Zotero10LocalAdapter::new("0123456789abcdef0123456789abcdef", "server-10").unwrap(); for key in ["ABCD234", "ABCD2340", "abcd2345", "ABCD2345/../X"] { assert_eq!( adapter.get_item(key).unwrap_err(), @@ -2739,7 +3170,7 @@ mod tests { 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", + "HTTP/1.1 500 Internal Server Error\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ]); assert_eq!( transport(base).get_item("ABCD2345").unwrap_err(), @@ -2752,7 +3183,7 @@ mod tests { 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", + "HTTP/1.1 500 Internal Server Error\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ]); assert_eq!( transport(base).get_item("ABCD2345").unwrap_err(), @@ -2760,6 +3191,22 @@ mod tests { ); server.join().unwrap(); + let switched = "HTTP/1.1 412 Precondition Failed\r\nZotero-Server-ID: other-server\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let (base, server) = serve(vec![switched]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + server.join().unwrap(); + + let before = library_response("server-10", 42); + let (base, server) = serve(vec![Box::leak(before.into_boxed_str()), switched]); + assert_eq!( + transport(base).get_item("ABCD2345").unwrap_err(), + ZoteroTransportError::ServerMismatch + ); + server.join().unwrap(); + for response in [ raw_response(None, Some(42), "{}"), raw_response(Some("server-10"), None, "{}"), @@ -2845,8 +3292,9 @@ mod tests { #[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")); + let adapter = + Zotero10LocalAdapter::new("0123456789abcdef0123456789abcdef", "server-10").unwrap(); + assert!(!std::any::type_name_of_val(&adapter).contains("0123456789abcdef0123456789abcdef")); assert_eq!( format!("{:?}", ZoteroTransportError::RequestFailed), "RequestFailed" diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index f582afd0..c0e5761e 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -45,7 +45,7 @@ fn failed_http_write_with_matching_observation_remains_indeterminate() { 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" + "HTTP/1.1 500 Internal Server Error\r\nZotero-Server-ID: server-10\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" .into(), library_response("server-10", 43), item_response("server-10", 43), @@ -62,7 +62,11 @@ fn failed_http_write_with_matching_observation_remains_indeterminate() { let receipt = execute_classification_write_plan( &plan, |key| adapter.get_item(key), - |request| adapter.write_item(request), + |request| { + let result = adapter.write_item(request); + assert_eq!(result, Err(ZoteroTransportError::RequestFailed)); + result + }, ); let requests = server.join().unwrap(); assert_eq!(requests.len(), 7); @@ -137,7 +141,7 @@ fn synthetic_server_retains_headers_and_body_larger_than_its_read_buffer() { #[test] fn authenticated_calls_never_use_environment_proxies() { let mut failures = Vec::new(); - for request_kind in ["read", "write"] { + for request_kind in ["read", "write", "authorize"] { for proxy_variable in [ "HTTP_PROXY", "http_proxy", @@ -200,6 +204,19 @@ fn authenticated_routing_child() { let Ok(request_kind) = std::env::var(PROXY_CHILD_CASE) else { return; }; + if request_kind == "authorize" { + let body = format!(r#"{{"key":"{SYNTHETIC_API_KEY}","remember":true}}"#); + let (result, server) = authorization_fixture("200 OK", &body); + assert!(result.unwrap().remembered()); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].starts_with("POST /api/local/authorize HTTP/1.1\r\n")); + assert!(requests[0].contains("zotero-server-id: server-10\r\n")); + assert!(requests[0].ends_with(r#"{"appName":"ConceptWeave"}"#)); + assert!(!requests[0].contains(SYNTHETIC_API_KEY)); + assert!(!requests[0].contains("zotero-api-key:")); + return; + } let responses = match request_kind.as_str() { "read" => vec![ library_response("server-10", 42), @@ -312,3 +329,58 @@ fn authenticated_write_rejects_one_byte_over_the_limit() { server.join().unwrap(); assert_eq!(result.unwrap_err(), ZoteroTransportError::InvalidResponse); } + +fn authorization_fixture( + status: &str, + body: &str, +) -> ( + Result, + thread::JoinHandle>, +) { + let response = authorize_response(status, Some("server-10"), body); + let (items_base, server) = serve(vec![Box::leak(response.into_boxed_str())]); + let result = Zotero10LocalAuthorization::request_with_base( + "ConceptWeave", + "server-10", + items_base.trim_end_matches("/api/users/0/items").into(), + ); + (result, server) +} + +#[test] +fn authorization_accepts_exactly_the_byte_limit() { + let mut body = format!(r#"{{"key":"{SYNTHETIC_API_KEY}","remember":true}}"#); + body.push_str(&" ".repeat(MAX_AUTH_RESPONSE_BYTES as usize - body.len())); + let (result, server) = authorization_fixture("200 OK", &body); + server.join().unwrap(); + assert!( + result + .expect("exact-limit authorization must succeed") + .remembered() + ); +} + +#[test] +fn authorization_denial_accepts_exactly_the_byte_limit() { + let mut body = r#"{"denied":true}"#.to_owned(); + body.push_str(&" ".repeat(MAX_AUTH_RESPONSE_BYTES as usize - body.len())); + let (result, server) = authorization_fixture("403 Forbidden", &body); + server.join().unwrap(); + assert!(matches!(result, Err(ZoteroTransportError::Denied))); +} + +#[test] +fn authorization_rejects_one_byte_over_the_limit_for_success_and_denial() { + for (status, mut body) in [ + ( + "200 OK", + format!(r#"{{"key":"{SYNTHETIC_API_KEY}","remember":true}}"#), + ), + ("403 Forbidden", r#"{"denied":true}"#.to_owned()), + ] { + body.push_str(&" ".repeat(MAX_AUTH_RESPONSE_BYTES as usize + 1 - body.len())); + let (result, server) = authorization_fixture(status, &body); + server.join().unwrap(); + assert!(matches!(result, Err(ZoteroTransportError::InvalidResponse))); + } +} diff --git a/crates/conceptweave-zotero/tests/zotero_local_api_transport_threat_model.rs b/crates/conceptweave-zotero/tests/zotero_local_api_transport_threat_model.rs new file mode 100644 index 00000000..60f7db05 --- /dev/null +++ b/crates/conceptweave-zotero/tests/zotero_local_api_transport_threat_model.rs @@ -0,0 +1,17 @@ +const THREAT_MODEL: &str = include_str!("../../../THREAT_MODEL.md"); + +#[test] +fn zotero_local_api_transport_boundary_is_explicit() { + for required_statement in [ + "http://localhost:23119/api/", + "Zotero-Server-ID is not cryptographic server authentication", + "hostile same-host process", + "enterprise-secure live write-back", + "fail closed", + ] { + assert!( + THREAT_MODEL.contains(required_statement), + "THREAT_MODEL.md must preserve the Zotero transport boundary: {required_statement}" + ); + } +} diff --git a/docs/PRD.md b/docs/PRD.md index 24ed0d30..d9270a3e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -80,7 +80,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 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. +The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index b3e85652..ad2838c9 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -109,4 +109,6 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures. -The Zotero 10+ transport is pinned to `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. +The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body is exactly parseable with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. + +Loopback pinning, redirect rejection, and `Zotero-Server-ID` continuity checks do not encrypt HTTP traffic carrying `Zotero-API-Key` and do not authenticate the local peer before that key is transmitted. `Zotero-Server-ID` is not cryptographic server authentication. Under the currently documented Zotero Local API there is no HTTPS or OS-authenticated IPC write endpoint for ConceptWeave to substitute. A hostile same-host process that can observe, bind, or interpose on the loopback endpoint therefore remains inside the unresolved credential-confidentiality threat boundary. As recorded in `THREAT_MODEL.md`, mock/local orchestration evidence is allowed, but enterprise-secure live write-back remains fail closed until Zotero provides a protected transport or an explicit product-security/governance decision narrows the supported threat model and accepts the residual same-host risk. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index e6f52f42..dffb71c9 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -18,7 +18,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. Dry-run enumerates every operation as not attempted. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid response, a follow-up read is observation only: matching before-state cannot prove a delayed request terminated, and matching after-state or a newer revision cannot prove which writer caused it. The receipt keeps the exact submitted request and optional observation, always names that item as indeterminate, and creates no inverse for that unconfirmed write. Earlier directly verified applied items and their inverse coordinates remain intact. The API key remains adapter-owned. Cross-item transactionality is not claimed, and source records and attachments are never deleted. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. 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. +The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. A caller may supply credentials directly or perform one official `/api/local/authorize` request with a bounded nonblank application name and expected server identity. Every authorization, read, and write response must repeat that identity before status classification. Success returns an exact 32-character header-safe key plus the remembered decision; denial requires same-server bounded JSON with `denied: true`. The private authorization wrapper can only disclose the remembered decision or be consumed into the existing adapter; neither value is debuggable or serializable. Denial and rate limiting never trigger an automatic retry or repeated prompt, and only a bounded integer retry delay is retained. Writes distinguish an expired authorization from a matching-server stale precondition, while a different-server `412` invalidates the read/write partition as a database switch. 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 @@ -84,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. -- Treating mock transport coverage as live proof was rejected because no approved Zotero 10 runtime/key write and rollback exercise has been performed. +- Treating mock transport coverage as live proof was rejected because no approved Zotero 10 authorization, runtime write, and rollback exercise has been performed. No live prompt ran and no key is committed. diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 782f30d9..2e3a58de 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -6,6 +6,8 @@ This file records the evidence basis for ConceptWeave architecture decisions. St 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 Local API authentication*. Zotero Documentation. https://www.zotero.org/support/dev/web_api/v3/local_api#authorizing_writes + 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/ diff --git a/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md b/docs/doctoring/RESEARCH_CAPABILITY_TRACEABILITY.md index dd158bd3..2731e2ca 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, 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. +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 one-shot runtime authorization, server partitioning, separate library/object version semantics, official key syntax, and one-item collection POST semantics for atomic preconditions. Mock transport fixtures exercise authorization success, denial, rate limiting, expired authorization, stale preconditions, and bounded responses without retaining a key. No live prompt ran; live Zotero 10 authorization, 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 fe05daa1..462a57e6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,37 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #18 local authorization successor verification (2026-09-06) + +Baseline `fe2cff4f9fc40496bbb4339ba4242543beacea9b` passed 108 tests/20 suites. +Normal merge `2dfec77ab32d7dde5a9c92c6c29bc91df4058294` retains that head and +parent `06d836a07fdb434683f88a31b45150a8a06f27f7`. Independent review verified +the authorization/transport block is identical to original PR #18 and the +write-plan/executor block identical to the repaired PR #17. No new runtime, +credential storage, revocation API or authority issuer was introduced. + +The inherited HTTP 500 fixture lacked server identity, so PR #18 rejected it +before interpreting status. RED `d5bc6e3` proves `InvalidResponse` differed from +the intended `RequestFailed`; `5790358` adds the matching server header while +retaining the explicit error assertion. The executor still keeps the exact +request, complete matching observation and proposal binding as indeterminate, +with exactly one POST and no inferred applied/inverse operations. Independent +read-only re-review found no further inheritance issue; this is not approval. + +Final source passed 131 tests/20 result suites including three doctests, strict +Clippy, warnings-denied rustdoc, format/CI-contract/diff and the unchanged coverage +gate: 247/247 functions, 2154/2154 normalized regions, 370/370 normalized branches. +Raw LLVM remains 2821/2874 lines, 4265/4349 regions and 330/370 branches, not 100%. +Logs use `/tmp/conceptweave-pr18-scope-` with `baseline.log`, `red.log`, `final.log`, +`clippy-final.log`, `rustdoc-final.log` and `coverage-final.log` suffixes. + +Native visual reinspection was attempted after verification but the Mac was +locked; no fresh screenshot was obtained. Previously verified 3,719 displayed +items remain historical display evidence only. Actual decisions/independent +approvals remain 0/3,715, plus four unresolved standalone sources. No real +authorization, write, recovery, protected merge or release occurred. PR #19 +approved execution must inherit this chain next; the root checkout remains older. + ### PR #17 authenticated transport successor verification (2026-09-06) Baseline `c88f9a34c1fc4e72e38cf66b1d2f3fcb305e560a` passed 100 tests/19 suites.