diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 2be7e611..e90ade25 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1819,6 +1819,23 @@ pub fn execute_classification_write_plan( } } +/// Executes a reviewed plan through one server-bound Zotero 10 adapter. +/// +/// The adapter may be created from a successful local authorization or from an +/// exact caller-owned local key. The existing execution core retains dry-run, +/// complete-preflight and receipt behavior. A failed write remains indeterminate; +/// matching observations do not grant completion, retry, or rollback authority. +pub fn execute_classification_write_plan_with_zotero10( + plan: &ClassificationWritePlan, + adapter: &Zotero10LocalAdapter, +) -> ClassificationWriteReceipt { + execute_classification_write_plan( + plan, + |item_key| adapter.get_item(item_key), + |request| adapter.write_item(request), + ) +} + fn matches_before_state( state: &ClassificationItemState, server_id: &str, @@ -2707,6 +2724,74 @@ mod tests { } } + #[test] + fn approved_zotero10_adapter_executes_the_reviewed_plan_boundary() { + let mut source_item = item("ABCD2345", "book", "ontology learning", "", ""); + source_item.data.collections = vec!["BCDE3456".into()]; + source_item.data.tags = vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }]; + let report = classify_snapshot( + "10.0.0".into(), + Some("server-10".into()), + 42, + vec![source_item], + ); + let review = ReviewedClassificationWriteSet { + review_id: "review-1".into(), + authority_receipt: "authority-1".into(), + server_id: report.server_id.clone(), + zotero_version: report.zotero_version.clone(), + library_version: report.library_version, + rule_revision: report.rule_revision.into(), + snapshot_digest: report.snapshot_digest.clone(), + proposal_digest: classification_proposal_digest(&report), + snapshot_items: report.snapshot_items.clone(), + changes: vec![ReviewedClassificationChange { + item_key: "ABCD2345".into(), + item_version: 7, + reviewed_disposition: Disposition::Generation, + before_collection_keys: vec!["BCDE3456".into()], + after_collection_keys: vec!["CDEF4567".into()], + before_tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + after_tags: vec![ItemTag { + tag: "classified".into(), + tag_type: None, + }], + }], + }; + let plan = build_classification_write_plan(&report, &review, WriteMode::Execute, |set| { + set == &review + }) + .unwrap(); + let before = library_response("server-10", 42); + let item = item_response("server-10", 7); + let after = library_response("server-10", 42); + let written_body = r#"{"successful":{"0":{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["CDEF4567"],"tags":[{"tag":"classified"}]}}}}"#; + let written = format!( + "HTTP/1.1 200 OK\r\nZotero-Server-ID: server-10\r\nLast-Modified-Version: 43\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{written_body}", + written_body.len() + ); + let (base, server) = serve(vec![ + Box::leak(before.into_boxed_str()), + Box::leak(item.into_boxed_str()), + Box::leak(after.into_boxed_str()), + Box::leak(written.into_boxed_str()), + ]); + + let receipt = execute_classification_write_plan_with_zotero10(&plan, &transport(base)); + + assert_eq!(receipt.outcome, ClassificationWriteOutcome::Applied); + assert_eq!(receipt.proposal_digest, review.proposal_digest); + assert_eq!(receipt.applied_item_keys, ["ABCD2345"]); + assert_eq!(receipt.rollback_operations[0].item_version, 43); + assert_eq!(server.join().unwrap().len(), 4); + } + #[test] fn zotero10_authorization_uses_exact_wire_contract_and_builds_adapter() { let body = r#"{"key":"0123456789abcdef0123456789abcdef","remember":true}"#; diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index c0e5761e..d17be9ed 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -7,37 +7,39 @@ 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![ + for use_public_boundary in [false, true] { + 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"), @@ -51,67 +53,72 @@ fn failed_http_write_with_matching_observation_remains_indeterminate() { 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| { - let result = adapter.write_item(request); - assert_eq!(result, Err(ZoteroTransportError::RequestFailed)); - result - }, - ); - 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) - ); + 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 = if use_public_boundary { + execute_classification_write_plan_with_zotero10(&plan, &adapter) + } else { + execute_classification_write_plan( + &plan, + |key| adapter.get_item(key), + |request| { + let result = adapter.write_item(request); + assert_eq!(result, Err(ZoteroTransportError::RequestFailed)); + result + }, + ) + }; + 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] diff --git a/docs/PRD.md b/docs/PRD.md index d9270a3e..867b24ba 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -78,9 +78,9 @@ For every connected duplicate component, accept externally verified steward deci Reviewed collection and tag changes default to a local dry-run plan. Each operation binds the authority receipt, server/library/item revisions, raw-snapshot digest, and complete before/after/rollback metadata. Execution-critical plan state is immutable outside the owner crate, so callers cannot turn a dry run into execution or alter validated operations. Zotero 9 execute requests fail closed. No plan contains credentials or permits `NeedsStewardReview`, source-record deletion, or attachment deletion. -For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt 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. +For execute-mode plans, the runtime must preflight every item before the first write and stop at the first failed or unverifiable response. Follow-up metadata reads are observations only: matching before or after values cannot establish whether the submitted request completed, terminated, or caused the observed change. A secret-free receipt retains the exact reviewed plan coordinates, proposal binding, submitted indeterminate request and optional observation. Dry-run receipts enumerate every planned item as untouched. Only directly verified successful write responses produce applied entries and reverse-ordered inverse operations; an unknown write produces neither retry nor rollback authority. Earlier verified operations remain recorded. Cross-item atomicity is not claimed. -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. +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. One public execution boundary connects this adapter to the reviewed plan without duplicating preflight, reconciliation, or rollback 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. 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/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index dffb71c9..1fcb7850 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. 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. +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. One public adapter execution boundary delegates to the existing reviewed execution core, preserving its complete preflight, reconciliation, and rollback receipt rather than creating parallel mutation logic. 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 @@ -56,6 +56,18 @@ adopt the required field without deriving fresh authority from serialized plans. ### Execution evidence correction (2026-09-06, Proposed) +PR #19 keeps its thin public adapter execution boundary and adopts the same +private proposal-bound plan and uncertainty semantics. Integration `bf4b2e5` +exposed the test's missing required digest. `785cdf5` uses the existing verified +plan builder and exercises both core and public wrapper on failed HTTP writes; +`5693f12` aligns the source fixture with the reviewed nonempty before-state after +the builder correctly rejected it as stale. No validator was relaxed. The test +verifier is synthetic and provides no real-world approval. A stale PRD paragraph +allowing an inverse for an observed unexpected mutation is corrected to match +the original-owner runtime and this decision. Existing delegation is retained +instead of adding a second execution path, accepting that genuine authorization, +independent approval and live recovery evidence remain separate outstanding gates. + 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 462a57e6..b53c6f97 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,42 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #19 approved execution successor verification (2026-09-06) + +Baseline `62c19ee23e3c827bc7db15c79f4755ff040489e9` passed 109 tests/20 suites. +Normal merge `bf4b2e561fdbd8e085c0d98dfa76ca97ad4d6ade` retains that head and +parent `b68d21aa24f355608c4673ad037cc2ff8af031f6`. Integration failed visibly +because the synthetic direct-plan constructor omitted required `proposal_digest`. +`785cdf5` replaces it with the existing report/review builder and checks the +output digest. This exposed a stale synthetic before-state; `5693f12` fixes the +source fixture, preserving the original nonempty-to-nonempty metadata scenario. +Neither required binding nor before-state validation was weakened. + +The existing failed-POST/matching-observation test now runs both the generic +executor and public adapter boundary. It retains the exact transport-error check +on the generic route and complete receipt/request/observation/digest, one POST, +and no inferred applied/inverse assertions on both. No second runtime execution +path or approval issuer was added. Independent review found no runtime bypass; +its fixture finding was addressed before final validation. + +Final source passed 132 tests/20 suites including three doctests, strict Clippy, +warnings-denied rustdoc, format/CI-contract/diff and unchanged coverage gates. +Functions 252/252, normalized regions 2164/2164, normalized branches 370/370; +raw LLVM 2900/2944 lines, 4393/4466 regions and 330/370 branches remain below 100%. +Logs use `/tmp/conceptweave-pr19-scope-` with `baseline.log`, `integration.log`, +`final.log` (stale fixture failure), `complete.log`, `clippy-complete.log`, +`rustdoc-complete.log` and `coverage-complete.log` suffixes. + +PRD's inherited unexpected-mutation inverse claim is removed; ADR 0007 remains +Proposed and records observation-only semantics and the rejected duplicate path. +No fresh visual evidence was collected; the latest attempt was blocked by the +locked Mac, and historical 3,719 displayed items do not establish reclassification. +Real decisions/independent approvals stay 0/3,715 plus four unresolved sources. +No real authorization, mutation, recovery, protected merge or release occurred. +Next verified successor is PR #20 `a03a7248c894a1e0765968ddf58514d98c517da3`; +rollback must retain unknown original-write scope rather than derive authority +from serialized audit data. Root checkout adoption remains outstanding. + ### PR #18 local authorization successor verification (2026-09-06) Baseline `fe2cff4f9fc40496bbb4339ba4242543beacea9b` passed 108 tests/20 suites.