Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fcd5e1f
test(zotero): specify approved execution boundary
seonghobae Sep 4, 2026
11d1eec
feat(zotero): connect reviewed plans to adapter
seonghobae Sep 4, 2026
0f5e24f
chore(zotero): adopt local transport threat model
seonghobae Sep 4, 2026
e2ed426
chore(zotero): adopt completed transport security docs
seonghobae Sep 4, 2026
2e6b860
Merge current authorization parent into approved execution
seonghobae Sep 4, 2026
79b1ab7
Merge repaired write receipt evidence into zotero10-approved-execution
seonghobae Sep 4, 2026
4d249be
chore(zotero): restack approved execution boundary
seonghobae Sep 4, 2026
4b01d85
Merge Zotero version receipt binding into zotero10-approved-execution
seonghobae Sep 4, 2026
48f9e7c
merge(zotero): adopt current authorization parent
seonghobae Sep 4, 2026
b6c11b2
merge(zotero): adopt current authorization parent and gap baseline
seonghobae Sep 5, 2026
9dc1387
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
6d42af5
merge(research): inherit canonical local transport repairs into PR #19
seonghobae Sep 5, 2026
aa74f86
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
de9df48
merge(zotero): propagate validated approval ordering through PR 19
seonghobae Sep 5, 2026
62c19ee
merge(research): inherit bounded metadata reads into PR #19
seonghobae Sep 6, 2026
bf4b2e5
Merge commit 'b68d21aa24f355608c4673ad037cc2ff8af031f6' into codex/zo…
seonghobae Sep 6, 2026
785cdf5
test(zotero): verify proposal-bound adapter execution
seonghobae Sep 6, 2026
5693f12
test(zotero): bind source fixture to reviewed before state
seonghobae Sep 6, 2026
6dc6176
docs(research): align approved execution evidence and recovery limits
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,23 @@ pub fn execute_classification_write_plan<PreflightError, WriteError>(
}
}

/// 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,
Expand Down Expand Up @@ -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}"#;
Expand Down
191 changes: 99 additions & 92 deletions crates/conceptweave-zotero/src/tests/authenticated_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading