From fcd5e1f5cc5d4f5403a89edff3e365aa5cce2181 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:18:13 +0900 Subject: [PATCH 1/5] test(zotero): specify approved execution boundary --- crates/conceptweave-zotero/src/lib.rs | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 1b048e86..2e0a8804 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2363,6 +2363,62 @@ mod tests { } } + #[test] + fn approved_zotero10_adapter_executes_the_reviewed_plan_boundary() { + let plan = ClassificationWritePlan { + mode: WriteMode::Execute, + review_id: "review-1".into(), + authority_receipt: "authority-1".into(), + server_id: Some("server-10".into()), + zotero_version: "10.0.0".into(), + library_version: 42, + rule_revision: "ontology-research-v2".into(), + snapshot_digest: "sha256:reviewed".into(), + operations: vec![ClassificationWriteOperation { + item_key: "ABCD2345".into(), + item_version: 7, + reviewed_disposition: Disposition::Generation, + before_collection_keys: vec!["BCDE3456".into()], + after_collection_keys: vec!["CDEF4567".into()], + rollback_collection_keys: vec!["BCDE3456".into()], + before_tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + after_tags: vec![ItemTag { + tag: "classified".into(), + tag_type: None, + }], + rollback_tags: vec![ItemTag { + tag: "kept".into(), + tag_type: Some(1), + }], + }], + source_records_preserved: true, + }; + 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.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}"#; From 11d1eec178bad6b09a4163cddd0bf165728c8a94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:19:01 +0900 Subject: [PATCH 2/5] feat(zotero): connect reviewed plans to adapter --- crates/conceptweave-zotero/src/lib.rs | 16 ++++++++++++++++ docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 2e0a8804..fb9099b8 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1618,6 +1618,22 @@ 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, reconciliation, and rollback-receipt behavior. +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, diff --git a/docs/PRD.md b/docs/PRD.md index c281d4a6..74babf8a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -64,7 +64,7 @@ Reviewed collection and tag changes default to a local dry-run plan. Each operat For execute-mode plans, the runtime must preflight every item before the first write, stop at the first failed or unverifiable response, reconcile that item through the same server before declaring its state, and emit a secret-free receipt. The receipt identifies verified writes, the failed item, any indeterminate item, untouched items, and reverse-ordered rollback operations bound to proven post-write item revisions. Cross-item atomicity is not claimed. -The Zotero 10+ adapter 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 bound to the canonical SHA-256 digest of the complete Zotero classification report plus its item-key/item-version coordinates. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot digest, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index aabea7ff..da2ce7c0 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -70,4 +70,4 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives a rollback coordinate even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. -The Zotero 10+ transport is pinned to 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. +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. A narrow adapter execution function reuses the existing complete preflight, same-boundary reconciliation, and reverse rollback receipt instead of introducing a second execution path. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 55c85242..51f8de7f 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -14,7 +14,7 @@ ConceptWeave builds a local-only `ClassificationWritePlan` from an externally ve Execute planning fails closed for Zotero versions below 10. The plan contains no API key and performs no network call. The execution core accepts caller-owned preflight and write functions, preflights the complete plan before the first mutation, and verifies server, library, item revision, collection, and typed-tag responses. After a failed or invalid write response, it reuses the same read boundary to distinguish unchanged, applied, and indeterminate state. A reconciled applied item receives a rollback operation; an unprovable state is named explicitly and requires operator reconciliation. -The authenticated Zotero 10+ adapter is a narrow loopback transport for those injected functions. 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 28ee8060..d1451a32 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,7 +42,7 @@ Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736d ### Zotero research classification slice -The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. Mock TCP fixtures only were used: no live prompt ran and no key is committed. +The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. A narrow successor execution boundary connects that adapter to the existing reviewed-plan preflight, reconciliation, and rollback receipt without duplicating mutation logic. Mock TCP fixtures only were used: no live prompt ran and no key is committed. Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. From 785cdf5d96baa1dbc55b81d8746081f73ff594ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:55:51 +0900 Subject: [PATCH 3/5] test(zotero): verify proposal-bound adapter execution --- crates/conceptweave-zotero/src/lib.rs | 37 ++-- .../src/tests/authenticated_transport.rs | 191 +++++++++--------- 2 files changed, 121 insertions(+), 107 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 26d02be9..5e616ef3 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1823,7 +1823,8 @@ pub fn execute_classification_write_plan( /// /// 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, reconciliation, and rollback-receipt behavior. +/// 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, @@ -2725,22 +2726,28 @@ mod tests { #[test] fn approved_zotero10_adapter_executes_the_reviewed_plan_boundary() { - let plan = ClassificationWritePlan { - mode: WriteMode::Execute, + let report = classify_snapshot( + "10.0.0".into(), + Some("server-10".into()), + 42, + vec![item("ABCD2345", "book", "ontology learning", "", "")], + ); + let review = ReviewedClassificationWriteSet { review_id: "review-1".into(), authority_receipt: "authority-1".into(), - server_id: Some("server-10".into()), - zotero_version: "10.0.0".into(), - library_version: 42, - rule_revision: "ontology-research-v2".into(), - snapshot_digest: "sha256:reviewed".into(), - operations: vec![ClassificationWriteOperation { + 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()], - rollback_collection_keys: vec!["BCDE3456".into()], before_tags: vec![ItemTag { tag: "kept".into(), tag_type: Some(1), @@ -2749,13 +2756,12 @@ mod tests { tag: "classified".into(), tag_type: None, }], - rollback_tags: vec![ItemTag { - tag: "kept".into(), - tag_type: Some(1), - }], }], - source_records_preserved: true, }; + 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); @@ -2774,6 +2780,7 @@ mod tests { 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); 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] From 5693f1200d3521350927cb7d9fe7aa182060c721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:57:05 +0900 Subject: [PATCH 4/5] test(zotero): bind source fixture to reviewed before state --- crates/conceptweave-zotero/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 5e616ef3..e90ade25 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -2726,11 +2726,17 @@ 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![item("ABCD2345", "book", "ontology learning", "", "")], + vec![source_item], ); let review = ReviewedClassificationWriteSet { review_id: "review-1".into(), From 6dc6176ff36443a11ce5d71e8f56d5d41c612deb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:58:37 +0900 Subject: [PATCH 5/5] docs(research): align approved execution evidence and recovery limits --- docs/PRD.md | 2 +- docs/adr/0007-reviewed-zotero-write-plan.md | 12 +++++++ docs/product-technical-gap-baseline.md | 36 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/PRD.md b/docs/PRD.md index 45be8136..867b24ba 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -78,7 +78,7 @@ 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. 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. diff --git a/docs/adr/0007-reviewed-zotero-write-plan.md b/docs/adr/0007-reviewed-zotero-write-plan.md index 2f3c73da..1fcb7850 100644 --- a/docs/adr/0007-reviewed-zotero-write-plan.md +++ b/docs/adr/0007-reviewed-zotero-write-plan.md @@ -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.