Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b9ce838
test(zotero): specify rollback execution boundary
seonghobae Sep 4, 2026
3119722
feat(zotero): execute verified rollback receipts
seonghobae Sep 4, 2026
b588e96
test(zotero): tighten rollback retry boundaries
seonghobae Sep 4, 2026
8d334a3
fix(zotero): fail closed on unsafe rollback retry
seonghobae Sep 4, 2026
af16e0d
docs(zotero): define rollback retry safety
seonghobae Sep 4, 2026
6683fba
test(zotero): preserve indeterminate rollback evidence
seonghobae Sep 4, 2026
e654eb7
fix(zotero): retain rollback reconciliation evidence
seonghobae Sep 4, 2026
9abc862
test(zotero): read complete mock requests
seonghobae Sep 4, 2026
dc6ef4a
style(zotero): format mock request reader
seonghobae Sep 4, 2026
70c1a85
test(zotero): exclude mock server plumbing from coverage
seonghobae Sep 4, 2026
c45e4cb
chore(zotero): restack rollback execution on security boundary
seonghobae Sep 4, 2026
c872e60
Merge current approved-execution parent into rollback execution
seonghobae Sep 4, 2026
071264f
Merge repaired write receipt evidence into zotero10-rollback-execution
seonghobae Sep 4, 2026
3166dfa
chore(zotero): restack rollback execution
seonghobae Sep 4, 2026
64e731c
Merge Zotero version receipt binding into zotero10-rollback-execution
seonghobae Sep 4, 2026
cf38e4c
merge(zotero): adopt current approved-execution parent
seonghobae Sep 4, 2026
53a91d3
merge(zotero): adopt current approved-execution parent and gap baseline
seonghobae Sep 5, 2026
683c081
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
d605e15
test(research): adopt captured-source rollback fixtures
seonghobae Sep 5, 2026
5b824c8
merge(research): preserve canonical transport and complete-request te…
seonghobae Sep 5, 2026
c31faaa
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
4fb1ad0
merge(zotero): propagate validated approval ordering through PR 20
seonghobae Sep 5, 2026
a03a724
merge(research): inherit bounded metadata reads into PR #20
seonghobae Sep 6, 2026
c53e36b
merge(research): preserve source scope in rollback successor
seonghobae Sep 6, 2026
e5f583a
test(zotero): retain rollback review proposal binding
seonghobae Sep 6, 2026
318b370
test(zotero): expose inverse write causal uncertainty
seonghobae Sep 6, 2026
4e28613
fix(zotero): preserve uncertain inverse requests and observations
seonghobae Sep 6, 2026
de407cf
test(zotero): type empty restored expectation explicitly
seonghobae Sep 6, 2026
ff66a54
test(zotero): assert complete inverse request and readback
seonghobae Sep 6, 2026
006efd0
test(zotero): retain uncertainty for invalid inverse responses
seonghobae Sep 6, 2026
876cbfe
refactor(zotero): remove obsolete rollback self-comparison
seonghobae Sep 6, 2026
b238bc6
docs(zotero): distinguish rollback audit data from authority
seonghobae Sep 6, 2026
000b37b
docs(research): record inverse uncertainty repair and open authority …
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
258 changes: 240 additions & 18 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,19 +863,60 @@ pub struct ClassificationWriteRequest {
pub tags: Vec<ItemTag>,
}

/// A conditional inverse write created only after a verified successful write.
/// Mutable inverse-write audit data; this value alone does not prove authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClassificationRollbackOperation {
/// Local API server identity that produced the state to undo.
pub server_id: String,
/// Stable Zotero item key.
pub item_key: String,
/// Post-write item revision required by a rollback adapter.
pub item_version: u64,
/// Complete post-write collection state that must still be current.
pub expected_collection_keys: Vec<String>,
/// Complete post-write typed-tag state that must still be current.
pub expected_tags: Vec<ItemTag>,
/// Complete collection state to restore.
pub collection_keys: Vec<String>,
/// Complete typed-tag state to restore.
pub tags: Vec<ItemTag>,
}

/// Observable result of one rollback execution attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ClassificationRollbackOutcome {
/// Every inverse write was verified.
Restored,
/// No inverse write began because complete preflight could not be proven.
PreflightFailure,
/// An inverse write or its response failed after preflight.
PartialFailure,
}

/// Secret-free evidence for restored, failed, indeterminate, and pending inverse writes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClassificationRollbackReceipt {
/// Overall rollback outcome.
pub outcome: ClassificationRollbackOutcome,
/// Items whose restored state was verified, in receipt order.
pub restored_item_keys: Vec<String>,
/// First item whose preflight, write, or response failed.
pub failed_item_key: Option<String>,
/// Item whose state could not be proven after an unverifiable response.
pub indeterminate_item_key: Option<String>,
/// Complete operation retained for manual reconciliation of indeterminate state.
pub indeterminate_operation: Option<ClassificationRollbackOperation>,
/// Exact submitted inverse request; observations do not authorize its retry.
pub indeterminate_request: Option<ClassificationWriteRequest>,
/// Complete readback after failure, retained without causal completion claims.
pub reconciliation_observation: Option<ClassificationItemState>,
/// Items whose inverse write was not attempted.
pub not_attempted_item_keys: Vec<String>,
/// Untouched operations only; this audit list does not grant retry authority.
pub remaining_operations: Vec<ClassificationRollbackOperation>,
}

/// Observable result of a write-plan execution attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -1792,8 +1833,11 @@ pub fn execute_classification_write_plan<PreflightError, WriteError>(
current_library_version = state.library_version;
applied_item_keys.push(operation.item_key.clone());
rollback_operations.push(ClassificationRollbackOperation {
server_id: server_id.to_owned(),
item_key: operation.item_key.clone(),
item_version: state.item_version,
expected_collection_keys: operation.after_collection_keys.clone(),
expected_tags: operation.after_tags.clone(),
collection_keys: operation.rollback_collection_keys.clone(),
tags: operation.rollback_tags.clone(),
});
Expand Down Expand Up @@ -1836,6 +1880,145 @@ pub fn execute_classification_write_plan_with_zotero10(
)
}

/// Executes caller-validated inverse operations; this primitive grants no authority.
pub fn execute_classification_rollback<PreflightError, WriteError>(
operations: &[ClassificationRollbackOperation],
mut preflight: impl FnMut(&str) -> Result<ClassificationItemState, PreflightError>,
mut write_item: impl FnMut(
&ClassificationWriteRequest,
) -> Result<ClassificationItemState, WriteError>,
) -> ClassificationRollbackReceipt {
if let Some(operation) = operations
.windows(2)
.find(|pair| pair[0].server_id != pair[1].server_id)
.map(|pair| &pair[1])
{
return rollback_preflight_failure(operations, &operation.item_key);
}

let mut preflight_states = Vec::with_capacity(operations.len());
let mut library_version = None;
for operation in operations {
let Ok(state) = preflight(&operation.item_key) else {
return rollback_preflight_failure(operations, &operation.item_key);
};
let same_library = library_version.is_none_or(|version| version == state.library_version);
if !same_library || !matches_rollback_current(&state, operation) {
return rollback_preflight_failure(operations, &operation.item_key);
}
library_version = Some(state.library_version);
preflight_states.push(state);
}

let mut current_library_version = library_version.unwrap_or_default();
let mut restored_item_keys = Vec::new();
for (index, (operation, state)) in operations.iter().zip(preflight_states).enumerate() {
let request = ClassificationWriteRequest {
server_id: operation.server_id.clone(),
library_version: current_library_version,
item_key: operation.item_key.clone(),
item_version: state.item_version,
collection_keys: operation.collection_keys.clone(),
tags: operation.tags.clone(),
};
let verified = write_item(&request)
.ok()
.filter(|state| matches_rollback_restored(state, current_library_version, operation));
if let Some(state) = verified {
current_library_version = state.library_version;
restored_item_keys.push(operation.item_key.clone());
continue;
}

let reconciled = preflight(&operation.item_key).ok();
return ClassificationRollbackReceipt {
outcome: ClassificationRollbackOutcome::PartialFailure,
restored_item_keys,
failed_item_key: Some(operation.item_key.clone()),
indeterminate_item_key: Some(operation.item_key.clone()),
indeterminate_operation: Some(operation.clone()),
indeterminate_request: Some(request),
reconciliation_observation: reconciled,
not_attempted_item_keys: operations[index + 1..]
.iter()
.map(|operation| operation.item_key.clone())
.collect(),
remaining_operations: operations[index + 1..].to_vec(),
};
}
ClassificationRollbackReceipt {
outcome: ClassificationRollbackOutcome::Restored,
restored_item_keys,
failed_item_key: None,
indeterminate_item_key: None,
indeterminate_operation: None,
indeterminate_request: None,
reconciliation_observation: None,
not_attempted_item_keys: Vec::new(),
remaining_operations: Vec::new(),
}
}

/// Executes caller-validated inverses through an adapter, without issuing approval.
pub fn execute_classification_rollback_with_zotero10(
operations: &[ClassificationRollbackOperation],
adapter: &Zotero10LocalAdapter,
) -> ClassificationRollbackReceipt {
execute_classification_rollback(
operations,
|item_key| adapter.get_item(item_key),
|request| adapter.write_item(request),
)
}

fn rollback_preflight_failure(
operations: &[ClassificationRollbackOperation],
failed_item_key: &str,
) -> ClassificationRollbackReceipt {
ClassificationRollbackReceipt {
outcome: ClassificationRollbackOutcome::PreflightFailure,
restored_item_keys: Vec::new(),
failed_item_key: Some(failed_item_key.to_owned()),
indeterminate_item_key: None,
indeterminate_operation: None,
indeterminate_request: None,
reconciliation_observation: None,
not_attempted_item_keys: operations
.iter()
.map(|item| item.item_key.clone())
.collect(),
remaining_operations: operations.to_vec(),
}
}

fn matches_rollback_current(
state: &ClassificationItemState,
operation: &ClassificationRollbackOperation,
) -> bool {
normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| {
state.server_id == operation.server_id
&& state.item_key == operation.item_key
&& state.item_version == operation.item_version
&& collections == operation.expected_collection_keys
&& tags == operation.expected_tags
})
}

fn matches_rollback_restored(
state: &ClassificationItemState,
library_version: u64,
operation: &ClassificationRollbackOperation,
) -> bool {
normalized_metadata(&state.collection_keys, &state.tags).is_ok_and(|(collections, tags)| {
state.server_id == operation.server_id
&& state.library_version > library_version
&& state.item_key == operation.item_key
&& state.item_version > operation.item_version
&& collections == operation.collection_keys
&& tags == operation.tags
})
}

fn matches_before_state(
state: &ClassificationItemState,
server_id: &str,
Expand Down Expand Up @@ -2607,6 +2790,7 @@ mod tests {

static LOCAL_API_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[cfg_attr(coverage_nightly, coverage(off))]
fn serve(responses: Vec<&'static str>) -> (String, std::thread::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
Expand All @@ -2617,25 +2801,27 @@ mod tests {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = Vec::new();
loop {
let mut buffer = [0; 4096];
let length = stream.read(&mut buffer).unwrap();
let mut chunk = [0; 4096];
let length = stream.read(&mut chunk).unwrap();
assert_ne!(length, 0);
bytes.extend_from_slice(&buffer[..length]);
if let Some(header_end) =
bytes.extend_from_slice(&chunk[..length]);
let Some(header_end) =
bytes.windows(4).position(|part| part == b"\r\n\r\n")
{
let headers = std::str::from_utf8(&bytes[..header_end]).unwrap();
let body_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")
.map(|value| value.parse::<usize>().unwrap())
})
.unwrap_or(0);
if bytes.len() >= header_end + 4 + body_length {
break;
}
else {
continue;
};
let headers = String::from_utf8_lossy(&bytes[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or_default();
if bytes.len() >= header_end + 4 + content_length {
break;
}
}
stream.write_all(response.as_bytes()).unwrap();
Expand Down Expand Up @@ -2792,6 +2978,42 @@ mod tests {
assert_eq!(server.join().unwrap().len(), 4);
}

#[test]
fn approved_zotero10_adapter_executes_the_rollback_boundary() {
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 before = library_response("server-10", 44);
let item_body = r#"{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["CDEF4567"],"tags":[{"tag":"classified"}]}}"#;
let item = raw_response(Some("server-10"), Some(43), item_body);
let after = library_response("server-10", 44);
let written = write_response("server-10", 45, 45);
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_rollback_with_zotero10(&[operation], &transport(base));

assert_eq!(receipt.outcome, ClassificationRollbackOutcome::Restored);
assert_eq!(receipt.restored_item_keys, ["ABCD2345"]);
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
Loading