diff --git a/CHANGELOG.md b/CHANGELOG.md index f830533a..92a2d3f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to ConceptWeave are documented here. - Fail-closed Draft -> Proposed -> Validated -> Reviewed -> Published lifecycle with explicit rejection and supersession. - Draft 2020-12 JSON Schema for the semantic-candidate public contract. - Standards and research doctoring covering stable W3C ontology standards, 2026 RDF/SHACL work in progress, Apache Ossie, and recent LLM ontology-engineering research. +- Read-only delayed reconciliation receipts for indeterminate Zotero rollback operations. ### Security diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index facfbaed..17729a1e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -894,6 +894,31 @@ pub enum ClassificationRollbackOutcome { PartialFailure, } +/// Legacy state vocabulary; metadata-only reconciliation emits only `Indeterminate`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClassificationRollbackState { + /// The restoration metadata is present at a newer item revision. + Restored, + /// The expected post-write metadata and item revision remain unchanged. + Unchanged, + /// The observed state proves neither safe outcome. + Indeterminate, +} + +/// Secret-free evidence from one delayed, read-only rollback reconciliation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationRollbackReconciliationReceipt { + /// Causal state remains indeterminate; metadata alone cannot settle the write. + pub state: ClassificationRollbackState, + /// Complete operation under reconciliation. + pub operation: ClassificationRollbackOperation, + /// Successfully observed state, absent when validation or reading failed. + pub observed_state: Option, + /// Legacy audit slot; this read-only observer never grants a retry operation. + pub retry_operation: Option, +} + /// Secret-free evidence for restored, failed, indeterminate, and pending inverse writes. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ClassificationRollbackReceipt { @@ -1971,6 +1996,38 @@ pub fn execute_classification_rollback_with_zotero10( ) } +/// Re-reads one indeterminate rollback operation without performing a write. +pub fn reconcile_classification_rollback( + operation: &ClassificationRollbackOperation, + read_item: impl FnOnce(&str) -> Result, +) -> ClassificationRollbackReconciliationReceipt { + let valid_operation = !operation.server_id.trim().is_empty() + && !operation.item_key.trim().is_empty() + && normalized_metadata( + &operation.expected_collection_keys, + &operation.expected_tags, + ) + .is_ok() + && normalized_metadata(&operation.collection_keys, &operation.tags).is_ok(); + let observed_state = valid_operation + .then(|| read_item(&operation.item_key).ok()) + .flatten(); + ClassificationRollbackReconciliationReceipt { + state: ClassificationRollbackState::Indeterminate, + operation: operation.clone(), + observed_state, + retry_operation: None, + } +} + +/// Reconciles one rollback operation through the server-bound Zotero 10 adapter. +pub fn reconcile_classification_rollback_with_zotero10( + operation: &ClassificationRollbackOperation, + adapter: &Zotero10LocalAdapter, +) -> ClassificationRollbackReconciliationReceipt { + reconcile_classification_rollback(operation, |item_key| adapter.get_item(item_key)) +} + fn rollback_preflight_failure( operations: &[ClassificationRollbackOperation], failed_item_key: &str, @@ -3014,6 +3071,52 @@ mod tests { assert_eq!(server.join().unwrap().len(), 4); } + #[test] + fn zotero10_adapter_reconciles_an_indeterminate_rollback_without_writing() { + let operation = ClassificationRollbackOperation { + server_id: "server-10".into(), + item_key: "ABCD2345".into(), + item_version: 43, + expected_collection_keys: vec!["CDEF4567".into()], + expected_tags: vec![ItemTag { + tag: "classified".into(), + tag_type: None, + }], + collection_keys: vec!["BCDE3456".into()], + tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + }; + let item_body = r#"{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["CDEF4567"],"tags":[{"tag":"classified"}]}}"#; + let responses: Vec<&'static str> = vec![ + Box::leak(library_response("server-10", 99).into_boxed_str()), + Box::leak(raw_response(Some("server-10"), Some(43), item_body).into_boxed_str()), + Box::leak(library_response("server-10", 99).into_boxed_str()), + ]; + let (base, server) = serve(responses); + + let receipt = reconcile_classification_rollback_with_zotero10(&operation, &transport(base)); + + assert_eq!(receipt.state, ClassificationRollbackState::Indeterminate); + assert!(receipt.retry_operation.is_none()); + assert_eq!(receipt.operation, operation); + assert_eq!( + receipt.observed_state, + Some(ClassificationItemState { + server_id: operation.server_id, + library_version: 99, + item_key: operation.item_key, + item_version: 43, + collection_keys: operation.expected_collection_keys, + tags: operation.expected_tags, + }) + ); + let requests = server.join().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests.iter().all(|request| request.starts_with("GET "))); + } + #[test] fn zotero10_authorization_uses_exact_wire_contract_and_builds_adapter() { let body = r#"{"key":"0123456789abcdef0123456789abcdef","remember":true}"#; diff --git a/crates/conceptweave-zotero/tests/classification_rollback_reconciliation.rs b/crates/conceptweave-zotero/tests/classification_rollback_reconciliation.rs new file mode 100644 index 00000000..18e11367 --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_rollback_reconciliation.rs @@ -0,0 +1,171 @@ +use conceptweave_zotero::{ + ClassificationItemState, ClassificationRollbackOperation, ClassificationRollbackState, ItemTag, + reconcile_classification_rollback, +}; + +fn tag(value: &str) -> ItemTag { + ItemTag { + tag: value.into(), + tag_type: None, + } +} + +fn operation() -> ClassificationRollbackOperation { + ClassificationRollbackOperation { + server_id: "server-1".into(), + item_key: "ABCDEFGH".into(), + item_version: 10, + expected_collection_keys: vec!["classified".into()], + expected_tags: vec![tag("Classified")], + collection_keys: vec!["source".into()], + tags: vec![tag("Imported")], + } +} + +fn state( + item_version: u64, + collection_keys: Vec, + tags: Vec, +) -> ClassificationItemState { + ClassificationItemState { + server_id: "server-1".into(), + library_version: 99, + item_key: "ABCDEFGH".into(), + item_version, + collection_keys, + tags, + } +} + +#[test] +fn later_reconciliation_preserves_metadata_without_settling_rollback() { + let operation = operation(); + let cases = [ + ( + state( + 11, + operation.collection_keys.clone(), + operation.tags.clone(), + ), + ClassificationRollbackState::Indeterminate, + ), + ( + state( + 10, + operation.expected_collection_keys.clone(), + operation.expected_tags.clone(), + ), + ClassificationRollbackState::Indeterminate, + ), + ( + state(11, vec!["other".into()], operation.tags.clone()), + ClassificationRollbackState::Indeterminate, + ), + ( + state( + 11, + operation.expected_collection_keys.clone(), + operation.expected_tags.clone(), + ), + ClassificationRollbackState::Indeterminate, + ), + ( + state( + 10, + operation.collection_keys.clone(), + operation.tags.clone(), + ), + ClassificationRollbackState::Indeterminate, + ), + ( + state(10, vec![" ".into()], operation.expected_tags.clone()), + ClassificationRollbackState::Indeterminate, + ), + ( + state( + 10, + operation.expected_collection_keys.clone(), + vec![tag("Other")], + ), + ClassificationRollbackState::Indeterminate, + ), + ( + state(11, operation.collection_keys.clone(), vec![tag("Other")]), + ClassificationRollbackState::Indeterminate, + ), + ]; + + for (observed, expected) in cases { + let receipt = + reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(observed.clone())); + assert_eq!(receipt.state, expected); + assert_eq!(receipt.operation, operation); + assert_eq!(receipt.observed_state, Some(observed)); + assert!(receipt.retry_operation.is_none()); + } +} + +#[test] +fn later_reconciliation_preserves_read_failures_and_rejects_wrong_identity() { + let operation = operation(); + let unreadable = reconcile_classification_rollback(&operation, |_| { + Err::("read_failed") + }); + assert_eq!(unreadable.state, ClassificationRollbackState::Indeterminate); + assert_eq!(unreadable.operation, operation); + assert!(unreadable.observed_state.is_none()); + assert!(unreadable.retry_operation.is_none()); + + let mut wrong_server = state( + 10, + operation.expected_collection_keys.clone(), + operation.expected_tags.clone(), + ); + wrong_server.server_id = "server-2".into(); + let mismatched = + reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(wrong_server.clone())); + assert_eq!(mismatched.state, ClassificationRollbackState::Indeterminate); + assert_eq!(mismatched.observed_state, Some(wrong_server)); + assert!(mismatched.retry_operation.is_none()); + + let mut wrong_item = state( + 10, + operation.expected_collection_keys.clone(), + operation.expected_tags.clone(), + ); + wrong_item.item_key = "BCDEFGHJ".into(); + assert_eq!( + reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(wrong_item)).state, + ClassificationRollbackState::Indeterminate + ); + + let mut invalid_operations = Vec::new(); + let mut blank_server = operation.clone(); + blank_server.server_id = " ".into(); + invalid_operations.push(blank_server); + let mut blank_item = operation.clone(); + blank_item.item_key = " ".into(); + invalid_operations.push(blank_item); + let mut invalid_expected = operation.clone(); + invalid_expected.expected_collection_keys.push(" ".into()); + invalid_operations.push(invalid_expected); + let mut invalid_restoration = operation.clone(); + invalid_restoration.collection_keys.push(" ".into()); + invalid_operations.push(invalid_restoration); + + for invalid_operation in invalid_operations { + let invalid = reconcile_classification_rollback( + &invalid_operation, + |_| -> Result { + panic!("invalid reconciliation evidence must fail before reading") + }, + ); + assert_eq!(invalid.state, ClassificationRollbackState::Indeterminate); + assert!(invalid.observed_state.is_none()); + assert!(invalid.retry_operation.is_none()); + + let serialized = serde_json::to_string(&invalid).unwrap(); + assert!(!serialized.to_ascii_lowercase().contains("api_key")); + assert!(!serialized.contains("read_failed")); + } +} diff --git a/docs/PRD.md b/docs/PRD.md index a4ba7004..febeb7f3 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -82,6 +82,8 @@ For execute-mode plans, the runtime must preflight every item before the first w Rollback must retain server-bound expected and restoration metadata, preflight every operation at one current library version, preserve receipt order and directly verified revision advancement. Uncertain original or inverse writes must not become successful recovery or retry authority. Failed-inverse inference is repaired locally: complete submitted requests and observations remain indeterminate. PR #20's operation-slice API still requires authoritative consumer integration preserving original-write scope before approved live use; an empty inverse list cannot establish that an unknown original write was recovered. +PR #21 retains validated delayed reads without writes and complete observed metadata. Metadata-only restored/unchanged and retry inference is removed in `f9c2c03`: the observer always retains indeterminate causal status and emits no retry operation. Eight metadata scenarios and the three-GET adapter fixture remain covered; the latter compares the entire observed state. Legacy enum variants and the optional retry field remain for contract compatibility, not as outputs or approval from this observer. Authoritative successor wrappers still must retain the complete prior rollback receipt, its exact submitted request, binding and untouched tail; an operation-only observation cannot replace that envelope or establish causal completion, termination, or independent retry authority. + 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. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. 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. diff --git a/docs/TRD.md b/docs/TRD.md index 88888dd4..ea43705d 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -111,6 +111,8 @@ The report is local JSON and contains proposals rather than governance decisions PR #20 retains its rollback core and adapter: mixed-server rejection precedes reads; complete current-state checks precede inverse writes; only directly verified responses advance the library version. Every failed or invalid inverse response now remains indeterminate, retaining the complete operation, exact submitted request (including its library precondition), and optional complete readback. Matching restored or unchanged metadata does not prove causal completion or termination, and the failed inverse is absent from remaining work. Earlier directly verified restorations remain recorded; remaining operations are untouched only, not automatic retry authority. The operation-slice API still lacks original-write scope and independent authority; authoritative consumer adoption remains an open gate, including empty-slice and delayed-reconciliation handling. +PR #21 retains validated delayed reads without writes and complete observed metadata. Metadata-only restored/unchanged and retry inference is removed in `f9c2c03`: the observer always retains indeterminate causal status and emits no retry operation. Eight metadata scenarios and the three-GET adapter fixture remain covered; the latter compares the entire observed state. Legacy enum variants and the optional retry field remain for contract compatibility, not as outputs or approval from this observer. Authoritative successor wrappers still must retain the complete prior rollback receipt, its exact submitted request, binding and untouched tail; an operation-only observation cannot replace that envelope or establish causal completion, termination, or independent retry authority. + 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 parses 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. Narrow adapter functions reuse the generic write and rollback cores. 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 f58a0a05..c616e359 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -20,6 +20,8 @@ Execute planning fails closed for Zotero versions below 10. The plan contains no 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. Thin public adapter boundaries delegate to the generic write and rollback cores rather than creating parallel mutation logic. Rollback evidence binds the server, post-write item revision, complete expected current metadata, and complete restoration metadata. Before its first read, rollback rejects evidence spanning server identities; before its first write, it verifies every item at one current library version. It then follows the already reversed receipt order and advances that version only from a verified write. A failed or unverifiable inverse response is re-read only as observation and always remains indeterminate. Its exact submitted request, full operation and optional observation are retained; matching metadata cannot prove completion or termination. Only untouched operations remain listed, without automatic retry authority. Public operation DTOs and empty operation slices are not complete original-write scope or independent approval; authoritative consumer wrappers must preserve those boundaries before live use. Reusing consumed evidence fails preflight before writing. Static errors and serializable receipts cannot echo a credential, response body, or URL. Cross-item transactionality is not claimed, and source records and attachments are never deleted. +PR #21 retains validated delayed reads without writes and complete observed metadata. Metadata-only restored/unchanged and retry inference is removed in `f9c2c03`: the observer always retains indeterminate causal status and emits no retry operation. Eight metadata scenarios and the three-GET adapter fixture remain covered; the latter compares the entire observed state. Legacy enum variants and the optional retry field remain for contract compatibility, not as outputs or approval from this observer. Authoritative successor wrappers still must retain the complete prior rollback receipt, its exact submitted request, binding and untouched tail; an operation-only observation cannot replace that envelope or establish causal completion, termination, or independent retry authority. + ## Consequences ### Source-scope amendment (2026-09-06, Proposed) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e55d8112..205fddf8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,40 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #21 delayed observation authority repair (2026-09-06) + +Untouched `09c84e4cdb1393a5e450f5200b87f292eeea956f` passed 119 tests/22 suites. +Normal merge `ec2baac` preserves that delta and repaired PR #20 `000b37b`; +integration passed 142 tests/22 suites. Conflicts retain the delayed observer +while preserving parent forward/inverse uncertainty and complete request fields. + +Behavioral RED `f2cf4ae` compiled and failed one of two reconciliation tests: +metadata-only observation returned `Restored` instead of `Indeterminate`. +Owner fix `f9c2c03` retains local operation validation, the single callback read, +complete operation and complete observation, but never infers completion or +termination and never emits a retry operation. The obsolete state classifier is +removed. All eight metadata cases remain; the adapter test compares the complete +state and proves its three HTTP requests are GETs. Legacy enum variants and the +optional retry slot remain compatible, not authority-producing outputs. + +At `f9c2c03`, 142 tests/22 suites including three doctests, strict Clippy, +warnings-denied rustdoc, format/CI-contract/diff and unchanged coverage pass: +268/268 functions, 2360/2360 normalized regions and 400/400 normalized branches. +Raw LLVM remains 3077/3137 lines, 4616/4713 regions and 356/400 branches, not 100%. +Logs use `/tmp/conceptweave-pr21-causal-` with `red.log`, `final.log`, `clippy.log`, +`rustdoc.log`, `coverage.log`; doc/test-name-only follow-up uses `complete.log` +and `rustdoc-complete.log`. Independent review found no blocking issue. + +PRD/TRD/Proposed ADR retain the distinct downstream gap: opaque full-text wrappers +must retain the entire prior rollback receipt, exact submitted request, binding +and untouched tail. An operation-only observation cannot substitute that envelope. +No new approval or capability layer was introduced. Next verified successor is +PR #22 review context `7179d13b45d160682e4cce1473c145d465fe657b`. +No fresh visual evidence was collected; the latest native attempt encountered +the locked Mac. Historical 3,719 displayed items are not classification evidence. +Actual decisions/approvals stay 0/3,715 plus four pending sources. No real +authorization, mutation, recovery, protected merge or release occurred. + ### PR #20 rollback uncertainty repair (2026-09-06) Untouched `a03a7248c894a1e0765968ddf58514d98c517da3` passed 116 tests/21 suites.