Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f816d8f
test(zotero): require classification report roundtrip
seonghobae Sep 4, 2026
bedfcff
feat(zotero): deserialize owner-only reports
seonghobae Sep 4, 2026
301e9c0
docs(zotero): define offline report roundtrip
seonghobae Sep 4, 2026
c3c25ad
style(zotero): format report roundtrip test
seonghobae Sep 4, 2026
9891f9b
test(zotero): preserve owned report revisions
seonghobae Sep 4, 2026
354ad81
test(zotero): clear owned rule revisions
seonghobae Sep 4, 2026
f185f5f
test(zotero): reject unbound restored child provenance
seonghobae Sep 4, 2026
7036959
fix(zotero): bind restored child provenance
seonghobae Sep 4, 2026
ae17dee
test(zotero): reject invalid restored child graphs
seonghobae Sep 4, 2026
4bf657b
fix(zotero): bind restored children to parents
seonghobae Sep 4, 2026
7883614
docs(zotero): preserve parent provenance coordinates
seonghobae Sep 4, 2026
3cd868a
style(zotero): format provenance validation
seonghobae Sep 4, 2026
0d8cced
test(zotero): cover blank parent provenance
seonghobae Sep 4, 2026
7bc8765
test(zotero): reject duplicate child sources
seonghobae Sep 4, 2026
92f4405
fix(zotero): preserve duplicate source parent provenance
seonghobae Sep 4, 2026
5065119
style(zotero): format duplicate provenance checks
seonghobae Sep 4, 2026
0ff44f9
test(zotero): reject incomplete child provenance
seonghobae Sep 4, 2026
afc93a5
fix(zotero): require complete child provenance
seonghobae Sep 4, 2026
c901b97
style(zotero): format child provenance check
seonghobae Sep 4, 2026
c1138cd
test(zotero): reject orphaned child provenance
seonghobae Sep 4, 2026
ff88db4
test(zotero): preserve nested child provenance
seonghobae Sep 4, 2026
b4e4a88
fix(zotero): allow nested child provenance
seonghobae Sep 4, 2026
4992eb5
style(zotero): format nested provenance fixture
seonghobae Sep 4, 2026
194e84d
test(zotero): clarify nested fixture title
seonghobae Sep 4, 2026
ee79496
Merge current worksheet-finalization parent into report roundtrip
seonghobae Sep 4, 2026
9ecd47a
Merge repaired write receipt evidence into zotero-report-roundtrip
seonghobae Sep 4, 2026
b751af9
Merge current receipt repair parent into zotero-report-roundtrip
seonghobae Sep 4, 2026
1623aad
Merge remote-tracking branch 'origin/autoresearch/zotero-worksheet-fi…
seonghobae Sep 4, 2026
459ebab
merge(zotero): adopt current worksheet-finalization parent
seonghobae Sep 4, 2026
2de9254
merge(zotero): adopt current worksheet-finalization parent and gap ba…
seonghobae Sep 5, 2026
ef685cb
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
25a787a
test(research): reject imported proposal mutation under original appr…
seonghobae Sep 5, 2026
9098b6f
merge(research): inherit canonical local transport repairs into PR #28
seonghobae Sep 5, 2026
d6e7b35
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
5c95bb7
test(zotero): preserve owned rule revision in original receipt fixture
seonghobae Sep 5, 2026
b411b66
merge(zotero): propagate validated approval ordering through PR 28
seonghobae Sep 5, 2026
ba6b3df
merge(research): inherit bounded metadata reads into PR #28
seonghobae Sep 6, 2026
13b5529
merge(research): preserve roundtrip coordinates and verified source b…
seonghobae Sep 6, 2026
e606c0f
fix(research): preserve owned revision values in inherited test receipts
seonghobae Sep 6, 2026
97fc490
test(research): reproduce restored parent-coordinate admission gaps
seonghobae Sep 6, 2026
1157b1d
fix(research): bind parent coordinates in shared report admission
seonghobae Sep 6, 2026
e102894
fix(zotero): preserve shared parent validation precedence
seonghobae Sep 6, 2026
29cc97a
docs(zotero): distinguish metadata restoration from raw capture
seonghobae Sep 6, 2026
63eb0f1
docs(zotero): record roundtrip source scope evidence
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 @@ -31,6 +31,7 @@ All notable changes to ConceptWeave are documented here.
- A snapshot-bound steward worksheet with one blank decision per bibliographic item and no duplicated bibliographic text.
- An explicit `--worksheet` CLI mode that writes the live worksheet with owner-only report protections.
- Fail-closed conversion from a fully decided worksheet to the existing externally verified golden-set boundary.
- Lossless owner-only classification-report deserialization for offline review finalization.

### Security

Expand Down
64 changes: 40 additions & 24 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,18 @@ pub enum AbstentionReason {
}

/// Evidence for a deterministic proposed disposition.
#[derive(Debug, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct ClassificationEvidence {
/// Metadata fields whose values matched.
pub fields: Vec<&'static str>,
pub fields: Vec<String>,
/// Exact snapshot values for matched fields, retained only in the local report.
pub field_values: BTreeMap<&'static str, String>,
pub field_values: BTreeMap<String, String>,
/// Rule phrases found in those fields.
pub matched_phrases: Vec<&'static str>,
pub matched_phrases: Vec<String>,
}

/// A single top-level bibliographic classification proposal.
#[derive(Debug, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct ClassifiedItem {
/// Stable Zotero item key.
pub item_key: String,
Expand Down Expand Up @@ -1044,7 +1044,7 @@ impl fmt::Display for WritePlanError {
impl std::error::Error for WritePlanError {}

/// Complete local classification report for one immutable library version.
#[derive(Debug, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct ClassificationReport {
/// Zotero desktop version that served the snapshot.
pub zotero_version: String,
Expand All @@ -1057,7 +1057,7 @@ pub struct ClassificationReport {
/// Library version shared by every fetched page.
pub library_version: u64,
/// Rule revision used for all proposals.
pub rule_revision: &'static str,
pub rule_revision: String,
/// Number of items read, including child notes and attachments.
pub observed_item_count: usize,
/// Complete item-revision identity of every observed record.
Expand Down Expand Up @@ -1086,7 +1086,7 @@ pub struct ClassificationReport {
}

/// Aggregate-only evidence that a successful report covers its input and proposals.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ClassificationAudit {
/// Records captured from the immutable snapshot.
pub snapshot_item_count: usize,
Expand Down Expand Up @@ -1165,6 +1165,7 @@ pub fn build_steward_review_worksheet(
if report.rule_revision.trim().is_empty() || report.snapshot_digest.trim().is_empty() {
return Err(WorksheetError::InvalidReport);
}

let mut decisions = Vec::with_capacity(report.classified_items.len());
for item in &report.classified_items {
if (item.proposed_disposition == Disposition::NeedsStewardReview)
Expand All @@ -1184,7 +1185,7 @@ pub fn build_steward_review_worksheet(

Ok(StewardReviewWorksheet {
library_version: report.library_version,
rule_revision: report.rule_revision.into(),
rule_revision: report.rule_revision.clone(),
snapshot_digest: report.snapshot_digest.clone(),
proposal_digest: classification_proposal_digest(report),
snapshot_items: report.snapshot_items.clone(),
Expand Down Expand Up @@ -1227,6 +1228,8 @@ pub struct SnapshotItemRevision {
pub item_key: String,
/// Item revision observed during review.
pub item_version: u64,
/// Parent item key for child records; absent for top-level records.
pub parent_item_key: Option<String>,
}

/// Governance receipt binding a steward approval to exact input and proposals.
Expand Down Expand Up @@ -1465,8 +1468,15 @@ pub fn validate_classification_report(
for item in &report.snapshot_items {
if item.item_key.trim().is_empty()
|| item.item_version > report.library_version
|| item
.parent_item_key
.as_ref()
.is_some_and(|key| key.trim().is_empty())
|| remaining_items
.insert(item.item_key.as_str(), item.item_version)
.insert(
item.item_key.as_str(),
(item.item_version, item.parent_item_key.as_deref()),
)
.is_some()
{
return Err(invalid);
Expand All @@ -1483,16 +1493,18 @@ pub fn validate_classification_report(
item.item_type.as_str(),
"attachment" | "note" | "annotation"
)
|| remaining_items.remove(item.item_key.as_str()) != Some(item.item_version)
|| remaining_items.remove(item.item_key.as_str()) != Some((item.item_version, None))
|| reported_children != actual_children
{
return Err(invalid);
}
}
for item in &report.unclassified_items {
let parent_key =
(!item.data.parent_item.is_empty()).then_some(item.data.parent_item.as_str());
if item.data.item_type.trim().is_empty()
|| is_bibliographic(item)
|| remaining_items.remove(item.key.as_str()) != Some(item.version)
|| remaining_items.remove(item.key.as_str()) != Some((item.version, parent_key))
{
return Err(invalid);
}
Expand Down Expand Up @@ -1658,10 +1670,10 @@ where
return Err(DuplicateReviewError::SnapshotMismatch);
}
validate_classification_report(report).map_err(|_| DuplicateReviewError::InvalidReview)?;
let item_revisions = report
let item_coordinates = report
.snapshot_items
.iter()
.map(|item| (item.item_key.as_str(), item.item_version))
.map(|item| (item.item_key.as_str(), item))
.collect::<BTreeMap<_, _>>();
let candidates = report
.duplicate_candidates
Expand Down Expand Up @@ -1731,12 +1743,10 @@ where
let source_items = component_keys
.iter()
.map(|item_key| {
item_revisions
item_coordinates
.get(item_key.as_str())
.map(|item_version| SnapshotItemRevision {
item_key: (*item_key).clone(),
item_version: *item_version,
})
.filter(|item| item.parent_item_key.is_none())
.map(|item| (*item).clone())
.ok_or(DuplicateReviewError::InvalidReview)
})
.collect::<Result<Vec<_>, _>>()?;
Expand All @@ -1762,6 +1772,7 @@ where
if !verify_review(reviewed) {
return Err(DuplicateReviewError::UnverifiedApproval);
}

operations.sort_by(|left, right| {
(&left.identity_kind, &left.normalized_identity)
.cmp(&(&right.identity_kind, &right.normalized_identity))
Expand Down Expand Up @@ -2655,6 +2666,8 @@ pub fn classify_snapshot(
.map(|item| SnapshotItemRevision {
item_key: item.key.clone(),
item_version: item.version,
parent_item_key: (!item.data.parent_item.is_empty())
.then(|| item.data.parent_item.clone()),
})
.collect();
let snapshot_records: Vec<_> = items
Expand Down Expand Up @@ -2692,7 +2705,7 @@ pub fn classify_snapshot(
schema_version: None,
server_id,
library_version,
rule_revision: RULE_REVISION,
rule_revision: RULE_REVISION.to_owned(),
observed_item_count,
snapshot_items,
snapshot_digest,
Expand Down Expand Up @@ -2933,9 +2946,12 @@ fn classify_item(item: &ZoteroItem, child_item_keys: Vec<String>) -> ClassifiedI
proposed_disposition,
abstention_reason,
evidence: ClassificationEvidence {
fields: matched_fields.into_iter().collect(),
field_values,
matched_phrases: matched_phrases.into_iter().collect(),
fields: matched_fields.into_iter().map(str::to_owned).collect(),
field_values: field_values
.into_iter()
.map(|(field, value)| (field.to_owned(), value))
.collect(),
matched_phrases: matched_phrases.into_iter().map(str::to_owned).collect(),
},
child_item_keys,
model_receipt: None,
Expand Down Expand Up @@ -3179,7 +3195,7 @@ mod tests {
server_id: report.server_id.clone(),
zotero_version: report.zotero_version.clone(),
library_version: report.library_version,
rule_revision: report.rule_revision.into(),
rule_revision: report.rule_revision.clone(),
snapshot_digest: report.snapshot_digest.clone(),
proposal_digest: classification_proposal_digest(&report),
snapshot_items: report.snapshot_items.clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fn failed_http_write_with_matching_observation_remains_indeterminate() {
server_id: report.server_id.clone(),
zotero_version: report.zotero_version.clone(),
library_version: report.library_version,
rule_revision: report.rule_revision.into(),
rule_revision: report.rule_revision.clone(),
snapshot_digest: report.snapshot_digest.clone(),
proposal_digest: classification_proposal_digest(&report),
snapshot_items: report.snapshot_items.clone(),
Expand Down
Loading