Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
74137cf
test(zotero): specify delayed rollback reconciliation
seonghobae Sep 4, 2026
eb9e30f
test(zotero): require reconciliation evidence receipts
seonghobae Sep 4, 2026
b7a22c7
feat(zotero): reconcile indeterminate rollbacks read only
seonghobae Sep 4, 2026
7ce522a
test(zotero): cover adapter reconciliation boundary
seonghobae Sep 4, 2026
b2120ad
docs(zotero): record delayed rollback reconciliation
seonghobae Sep 4, 2026
57b065c
style(zotero): format reconciliation contract
seonghobae Sep 4, 2026
b3c24ab
test(zotero): type reconciliation fixtures immutably
seonghobae Sep 4, 2026
6114b57
test(zotero): cover reconciliation trust boundaries
seonghobae Sep 4, 2026
6d57abd
style(zotero): format reconciliation tests
seonghobae Sep 4, 2026
6221c72
chore(zotero): restack reconciliation on security boundary
seonghobae Sep 4, 2026
2a020cb
Merge current rollback parent into rollback reconciliation
seonghobae Sep 4, 2026
b3600ff
Merge repaired write receipt evidence into zotero10-rollback-reconcil…
seonghobae Sep 4, 2026
bd6b9e1
chore(zotero): restack rollback reconciliation
seonghobae Sep 4, 2026
5084064
docs: retain Zotero version in rollback evidence
seonghobae Sep 4, 2026
717ccfb
Merge Zotero version receipt binding into PR 21
seonghobae Sep 4, 2026
18c4de1
merge(zotero): adopt current rollback-execution parent
seonghobae Sep 4, 2026
d51762d
merge(zotero): adopt current rollback parent and gap baseline
seonghobae Sep 5, 2026
05051d8
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
67c64ab
merge(research): inherit canonical local transport repairs into PR #21
seonghobae Sep 5, 2026
90d9665
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
9302f85
merge(zotero): propagate validated approval ordering through PR 21
seonghobae Sep 5, 2026
09c84e4
merge(research): inherit bounded metadata reads into PR #21
seonghobae Sep 6, 2026
ec2baac
merge(research): preserve inverse uncertainty in delayed observer
seonghobae Sep 6, 2026
f2cf4ae
test(zotero): reject delayed metadata as retry authority
seonghobae Sep 6, 2026
f9c2c03
fix(zotero): keep delayed rollback observations nonauthoritative
seonghobae Sep 6, 2026
7302e95
docs(zotero): clarify delayed observer compatibility vocabulary
seonghobae Sep 6, 2026
425d8df
docs(research): record delayed observation repair and envelope gap
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All notable changes to ConceptWeave are documented here.
- Fail-closed Draft -> Proposed -> Validated -> Reviewed -> Published lifecycle with explicit rejection and supersession.
- Draft 2020-12 JSON Schema for the semantic-candidate public contract.
- Standards and research doctoring covering stable W3C ontology standards, 2026 RDF/SHACL work in progress, Apache Ossie, and recent LLM ontology-engineering research.
- Read-only delayed reconciliation receipts for indeterminate Zotero rollback operations.

### Security

Expand Down
103 changes: 103 additions & 0 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,31 @@ pub enum ClassificationRollbackOutcome {
PartialFailure,
}

/// Legacy state vocabulary; metadata-only reconciliation emits only `Indeterminate`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ClassificationRollbackState {
/// The restoration metadata is present at a newer item revision.
Restored,
/// The expected post-write metadata and item revision remain unchanged.
Unchanged,
/// The observed state proves neither safe outcome.
Indeterminate,
}

/// Secret-free evidence from one delayed, read-only rollback reconciliation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClassificationRollbackReconciliationReceipt {
/// Causal state remains indeterminate; metadata alone cannot settle the write.
pub state: ClassificationRollbackState,
/// Complete operation under reconciliation.
pub operation: ClassificationRollbackOperation,
/// Successfully observed state, absent when validation or reading failed.
pub observed_state: Option<ClassificationItemState>,
/// Legacy audit slot; this read-only observer never grants a retry operation.
pub retry_operation: Option<ClassificationRollbackOperation>,
}

/// Secret-free evidence for restored, failed, indeterminate, and pending inverse writes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ClassificationRollbackReceipt {
Expand Down Expand Up @@ -1971,6 +1996,38 @@ pub fn execute_classification_rollback_with_zotero10(
)
}

/// Re-reads one indeterminate rollback operation without performing a write.
pub fn reconcile_classification_rollback<ReadError>(
operation: &ClassificationRollbackOperation,
read_item: impl FnOnce(&str) -> Result<ClassificationItemState, ReadError>,
) -> ClassificationRollbackReconciliationReceipt {
let valid_operation = !operation.server_id.trim().is_empty()
&& !operation.item_key.trim().is_empty()
&& normalized_metadata(
&operation.expected_collection_keys,
&operation.expected_tags,
)
.is_ok()
&& normalized_metadata(&operation.collection_keys, &operation.tags).is_ok();
let observed_state = valid_operation
.then(|| read_item(&operation.item_key).ok())
.flatten();
ClassificationRollbackReconciliationReceipt {
state: ClassificationRollbackState::Indeterminate,
operation: operation.clone(),
observed_state,
retry_operation: None,
}
}

/// Reconciles one rollback operation through the server-bound Zotero 10 adapter.
pub fn reconcile_classification_rollback_with_zotero10(
operation: &ClassificationRollbackOperation,
adapter: &Zotero10LocalAdapter,
) -> ClassificationRollbackReconciliationReceipt {
reconcile_classification_rollback(operation, |item_key| adapter.get_item(item_key))
}

fn rollback_preflight_failure(
operations: &[ClassificationRollbackOperation],
failed_item_key: &str,
Expand Down Expand Up @@ -3014,6 +3071,52 @@ mod tests {
assert_eq!(server.join().unwrap().len(), 4);
}

#[test]
fn zotero10_adapter_reconciles_an_indeterminate_rollback_without_writing() {
let operation = ClassificationRollbackOperation {
server_id: "server-10".into(),
item_key: "ABCD2345".into(),
item_version: 43,
expected_collection_keys: vec!["CDEF4567".into()],
expected_tags: vec![ItemTag {
tag: "classified".into(),
tag_type: None,
}],
collection_keys: vec!["BCDE3456".into()],
tags: vec![ItemTag {
tag: "kept".into(),
tag_type: Some(1),
}],
};
let item_body = r#"{"key":"ABCD2345","version":43,"data":{"itemType":"book","collections":["CDEF4567"],"tags":[{"tag":"classified"}]}}"#;
let responses: Vec<&'static str> = vec![
Box::leak(library_response("server-10", 99).into_boxed_str()),
Box::leak(raw_response(Some("server-10"), Some(43), item_body).into_boxed_str()),
Box::leak(library_response("server-10", 99).into_boxed_str()),
];
let (base, server) = serve(responses);

let receipt = reconcile_classification_rollback_with_zotero10(&operation, &transport(base));

assert_eq!(receipt.state, ClassificationRollbackState::Indeterminate);
assert!(receipt.retry_operation.is_none());
assert_eq!(receipt.operation, operation);
assert_eq!(
receipt.observed_state,
Some(ClassificationItemState {
server_id: operation.server_id,
library_version: 99,
item_key: operation.item_key,
item_version: 43,
collection_keys: operation.expected_collection_keys,
tags: operation.expected_tags,
})
);
let requests = server.join().unwrap();
assert_eq!(requests.len(), 3);
assert!(requests.iter().all(|request| request.starts_with("GET ")));
}

#[test]
fn zotero10_authorization_uses_exact_wire_contract_and_builds_adapter() {
let body = r#"{"key":"0123456789abcdef0123456789abcdef","remember":true}"#;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use conceptweave_zotero::{
ClassificationItemState, ClassificationRollbackOperation, ClassificationRollbackState, ItemTag,
reconcile_classification_rollback,
};

fn tag(value: &str) -> ItemTag {
ItemTag {
tag: value.into(),
tag_type: None,
}
}

fn operation() -> ClassificationRollbackOperation {
ClassificationRollbackOperation {
server_id: "server-1".into(),
item_key: "ABCDEFGH".into(),
item_version: 10,
expected_collection_keys: vec!["classified".into()],
expected_tags: vec![tag("Classified")],
collection_keys: vec!["source".into()],
tags: vec![tag("Imported")],
}
}

fn state(
item_version: u64,
collection_keys: Vec<String>,
tags: Vec<ItemTag>,
) -> ClassificationItemState {
ClassificationItemState {
server_id: "server-1".into(),
library_version: 99,
item_key: "ABCDEFGH".into(),
item_version,
collection_keys,
tags,
}
}

#[test]
fn later_reconciliation_preserves_metadata_without_settling_rollback() {
let operation = operation();
let cases = [
(
state(
11,
operation.collection_keys.clone(),
operation.tags.clone(),
),
ClassificationRollbackState::Indeterminate,
),
(
state(
10,
operation.expected_collection_keys.clone(),
operation.expected_tags.clone(),
),
ClassificationRollbackState::Indeterminate,
),
(
state(11, vec!["other".into()], operation.tags.clone()),
ClassificationRollbackState::Indeterminate,
),
(
state(
11,
operation.expected_collection_keys.clone(),
operation.expected_tags.clone(),
),
ClassificationRollbackState::Indeterminate,
),
(
state(
10,
operation.collection_keys.clone(),
operation.tags.clone(),
),
ClassificationRollbackState::Indeterminate,
),
(
state(10, vec![" ".into()], operation.expected_tags.clone()),
ClassificationRollbackState::Indeterminate,
),
(
state(
10,
operation.expected_collection_keys.clone(),
vec![tag("Other")],
),
ClassificationRollbackState::Indeterminate,
),
(
state(11, operation.collection_keys.clone(), vec![tag("Other")]),
ClassificationRollbackState::Indeterminate,
),
];

for (observed, expected) in cases {
let receipt =
reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(observed.clone()));
assert_eq!(receipt.state, expected);
assert_eq!(receipt.operation, operation);
assert_eq!(receipt.observed_state, Some(observed));
assert!(receipt.retry_operation.is_none());
}
}

#[test]
fn later_reconciliation_preserves_read_failures_and_rejects_wrong_identity() {
let operation = operation();
let unreadable = reconcile_classification_rollback(&operation, |_| {
Err::<ClassificationItemState, _>("read_failed")
});
assert_eq!(unreadable.state, ClassificationRollbackState::Indeterminate);
assert_eq!(unreadable.operation, operation);
assert!(unreadable.observed_state.is_none());
assert!(unreadable.retry_operation.is_none());

let mut wrong_server = state(
10,
operation.expected_collection_keys.clone(),
operation.expected_tags.clone(),
);
wrong_server.server_id = "server-2".into();
let mismatched =
reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(wrong_server.clone()));
assert_eq!(mismatched.state, ClassificationRollbackState::Indeterminate);
assert_eq!(mismatched.observed_state, Some(wrong_server));
assert!(mismatched.retry_operation.is_none());

let mut wrong_item = state(
10,
operation.expected_collection_keys.clone(),
operation.expected_tags.clone(),
);
wrong_item.item_key = "BCDEFGHJ".into();
assert_eq!(
reconcile_classification_rollback(&operation, |_| Ok::<_, ()>(wrong_item)).state,
ClassificationRollbackState::Indeterminate
);

let mut invalid_operations = Vec::new();
let mut blank_server = operation.clone();
blank_server.server_id = " ".into();
invalid_operations.push(blank_server);
let mut blank_item = operation.clone();
blank_item.item_key = " ".into();
invalid_operations.push(blank_item);
let mut invalid_expected = operation.clone();
invalid_expected.expected_collection_keys.push(" ".into());
invalid_operations.push(invalid_expected);
let mut invalid_restoration = operation.clone();
invalid_restoration.collection_keys.push(" ".into());
invalid_operations.push(invalid_restoration);

for invalid_operation in invalid_operations {
let invalid = reconcile_classification_rollback(
&invalid_operation,
|_| -> Result<ClassificationItemState, ()> {
panic!("invalid reconciliation evidence must fail before reading")
},
);
assert_eq!(invalid.state, ClassificationRollbackState::Indeterminate);
assert!(invalid.observed_state.is_none());
assert!(invalid.retry_operation.is_none());

let serialized = serde_json::to_string(&invalid).unwrap();
assert!(!serialized.to_ascii_lowercase().contains("api_key"));
assert!(!serialized.contains("read_failed"));
}
}
2 changes: 2 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ For execute-mode plans, the runtime must preflight every item before the first w

Rollback must retain server-bound expected and restoration metadata, preflight every operation at one current library version, preserve receipt order and directly verified revision advancement. Uncertain original or inverse writes must not become successful recovery or retry authority. Failed-inverse inference is repaired locally: complete submitted requests and observations remain indeterminate. PR #20's operation-slice API still requires authoritative consumer integration preserving original-write scope before approved live use; an empty inverse list cannot establish that an unknown original write was recovered.

PR #21 retains validated delayed reads without writes and complete observed metadata. Metadata-only restored/unchanged and retry inference is removed in `f9c2c03`: the observer always retains indeterminate causal status and emits no retry operation. Eight metadata scenarios and the three-GET adapter fixture remain covered; the latter compares the entire observed state. Legacy enum variants and the optional retry field remain for contract compatibility, not as outputs or approval from this observer. Authoritative successor wrappers still must retain the complete prior rollback receipt, its exact submitted request, binding and untouched tail; an operation-only observation cannot replace that envelope or establish causal completion, termination, or independent retry authority.

The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement.

Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result.
Expand Down
2 changes: 2 additions & 0 deletions docs/TRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ The report is local JSON and contains proposals rather than governance decisions

PR #20 retains its rollback core and adapter: mixed-server rejection precedes reads; complete current-state checks precede inverse writes; only directly verified responses advance the library version. Every failed or invalid inverse response now remains indeterminate, retaining the complete operation, exact submitted request (including its library precondition), and optional complete readback. Matching restored or unchanged metadata does not prove causal completion or termination, and the failed inverse is absent from remaining work. Earlier directly verified restorations remain recorded; remaining operations are untouched only, not automatic retry authority. The operation-slice API still lacks original-write scope and independent authority; authoritative consumer adoption remains an open gate, including empty-slice and delayed-reconciliation handling.

PR #21 retains validated delayed reads without writes and complete observed metadata. Metadata-only restored/unchanged and retry inference is removed in `f9c2c03`: the observer always retains indeterminate causal status and emits no retry operation. Eight metadata scenarios and the three-GET adapter fixture remain covered; the latter compares the entire observed state. Legacy enum variants and the optional retry field remain for contract compatibility, not as outputs or approval from this observer. Authoritative successor wrappers still must retain the complete prior rollback receipt, its exact submitted request, binding and untouched tail; an operation-only observation cannot replace that envelope or establish causal completion, termination, or independent retry authority.

The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed.

Loopback pinning, redirect rejection, and `Zotero-Server-ID` continuity checks do not encrypt HTTP traffic carrying `Zotero-API-Key` and do not authenticate the local peer before that key is transmitted. `Zotero-Server-ID` is not cryptographic server authentication. Under the currently documented Zotero Local API there is no HTTPS or OS-authenticated IPC write endpoint for ConceptWeave to substitute. A hostile same-host process that can observe, bind, or interpose on the loopback endpoint therefore remains inside the unresolved credential-confidentiality threat boundary. As recorded in `THREAT_MODEL.md`, mock/local orchestration evidence is allowed, but enterprise-secure live write-back remains fail closed until Zotero provides a protected transport or an explicit product-security/governance decision narrows the supported threat model and accepts the residual same-host risk.
Loading