From f816d8f6cd42bd05d5247140e51afa4363f57f84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:06:11 +0900 Subject: [PATCH 01/32] test(zotero): require classification report roundtrip --- .../tests/classification_report_roundtrip.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/classification_report_roundtrip.rs diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs new file mode 100644 index 00000000..1dd2aa78 --- /dev/null +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -0,0 +1,37 @@ +use conceptweave_zotero::{ + ClassificationReport, ItemData, ZoteroItem, build_steward_review_worksheet, + classify_snapshot, +}; + +#[test] +fn owner_only_report_roundtrip_preserves_the_review_workload() { + let mut item = ZoteroItem { + key: "ITEM".into(), + version: 7, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology alignment".into(), + abstract_note: "review context".into(), + doi: "10.1000/example".into(), + parent_item: String::new(), + collections: vec!["COLLECTION".into()], + tags: vec![], + }, + }; + item.data.tags.push(conceptweave_zotero::ItemTag { + tag: "ontology".into(), + tag_type: Some(1), + }); + let original = classify_snapshot("9.0.6".into(), None, 42, vec![item]); + let serialized = serde_json::to_vec(&original).unwrap(); + + let restored: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + + assert_eq!(restored.library_version, original.library_version); + assert_eq!(restored.rule_revision, original.rule_revision); + assert_eq!(restored.snapshot_digest, original.snapshot_digest); + assert_eq!( + build_steward_review_worksheet(&restored).unwrap(), + build_steward_review_worksheet(&original).unwrap() + ); +} From bedfcff74ffc5653f8903e21d431f6bc6f6e43e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:06:41 +0900 Subject: [PATCH 02/32] feat(zotero): deserialize owner-only reports --- crates/conceptweave-zotero/src/lib.rs | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index adb8d9d6..d6c1a53f 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -110,18 +110,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, /// Exact snapshot values for matched fields, retained only in the local report. - pub field_values: BTreeMap<&'static str, String>, + pub field_values: BTreeMap, /// Rule phrases found in those fields. - pub matched_phrases: Vec<&'static str>, + pub matched_phrases: Vec, } /// 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, @@ -981,7 +981,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, @@ -994,7 +994,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. @@ -1010,7 +1010,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, @@ -1152,7 +1152,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(), snapshot_items: report.snapshot_items.clone(), decisions, @@ -2510,7 +2510,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: items.len(), snapshot_items, snapshot_digest, @@ -2684,9 +2684,12 @@ fn classify_item(item: &ZoteroItem, child_item_keys: Vec) -> 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, From 301e9c0bbd503bee8735e73c4f7d41087a73c537 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:07:08 +0900 Subject: [PATCH 03/32] docs(zotero): define offline report roundtrip --- CHANGELOG.md | 1 + docs/TRD.md | 1 + docs/adr/0006-zotero-research-intake.md | 2 ++ docs/product-technical-gap-baseline.md | 2 ++ 4 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7ae9e4..4f7f3475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,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 diff --git a/docs/TRD.md b/docs/TRD.md index 463521a9..741d1638 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -70,6 +70,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The review worksheet is a deterministic item-key-ordered projection of the report. `conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` reads one live snapshot and creates both owner-only outputs as a bound pair; failed output removes partial files so the same paths can be retried. It binds the library version, rule revision, raw-snapshot digest, complete parent/child item coordinates, item proposal, abstention reason, and an initially empty decision for every bibliographic item. Construction rejects blank or duplicate snapshot identity, mismatched item revisions, and inconsistent observed, bibliographic, proposal, provenance, abstention, duplicate, failure, or disposition counts. It deliberately omits bibliographic text and matched evidence; stewards consult the paired owner-only report by item key. The finalization function consumes the filled worksheet plus a governance approval receipt. It rejects blank authority metadata, coordinate drift, duplicate or unknown decision keys, missing decisions, abstention as approved truth, and tampered proposal/reason pairs. Its output reuses the existing reviewed-golden-set evaluator; finalization itself does not verify external authority. +The owner-only report uses owned JSON values and supports lossless deserialization. A serialize/deserialize roundtrip must preserve the report-derived worksheet exactly, allowing later offline finalization against the original snapshot rather than another live Zotero read. The local report can contain titles, tags, matched metadata, and abstention abstracts. It is sensitive steward-review material, remains outside the repository, and is not a publication artifact. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 7f6d5add..4f58c9f6 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -26,6 +26,8 @@ Every successful report includes an aggregate audit summary computed from the sa To make full review executable without copying sensitive text again, ConceptWeave derives a local worksheet from the validated report. The worksheet carries the exact snapshot binding, complete parent/child revision coordinates, deterministic proposal and abstention reason, plus one empty steward-decision slot per bibliographic item in item-key order. Titles, abstracts, tags, collections, and matched evidence remain only in the owner-only report. Invalid report identity or coverage fails worksheet construction. +The report is a losslessly deserializable owner-only artifact. Evidence field names, matched phrases, and the rule revision use owned values so an offline process can reconstruct the exact canonical worksheet from the saved report; it must not reread a mutable Zotero library to finalize an earlier review. + The reader fails closed above 50,000 items or 256 MiB of cumulative response bodies, while retaining the 8 MiB per-page bound, finite request timeouts, redirect denial, total-count checks, snapshot-version checks, and duplicate-key detection. Pagination, consistency, resource-budget, and provider-contract behavior are separated from the narrow `ureq` transport so deterministic tests exercise the production reader core rather than excluding the entire reader from coverage. Report output is restricted to a new direct child of the operating system temporary directory. Relative paths, nested paths, existing files, and symlinks are rejected before write; the file is opened with create-new semantics so a path swap cannot cause repository or arbitrary-file overwrite. The buffered writer is explicitly flushed and a final filesystem error fails the command. Reports stay local and are never committed. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c7dc359f..c5cca9c6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -54,6 +54,8 @@ The steward workload now has a deterministic local worksheet contract rather tha Filled worksheets now have a fail-closed conversion into the existing reviewed golden-set contract. The conversion checks exact approval coordinates, complete unique decision identity, snapshot membership and item revision, proposal/abstention consistency, and rejects missing or abstention truth labels before the external authority verifier can run. The live completion KPI remains 0/3,715 until real steward decisions and an externally verified approval receipt exist. +The paired owner-only report is now losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. This removes the need to reread a mutable Zotero library when the completed worksheet is later finalized. A CLI import/finalization command remains the next operator-facing Gap. + The completion KPI is now executable: a sampled golden set may measure classifier quality, but it cannot prove that the library was fully reclassified. The full-review boundary returns aggregate completion evidence only when externally verified, snapshot-bound, non-abstention labels cover every unique classified bibliographic item exactly once. The current live baseline therefore remains incomplete at 0/3,715 approved labels rather than treating 57 deterministic proposals or a future sample as steward truth. 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local credential resolution; explicit read-only mode; statement timeout, cancellation, row/byte/concurrency budgets; complete immutable snapshot or fail closed; deterministic replay against a frozen anonymized GRC-shaped fixture. From c3c25adf206053908c5d158bee8f03d88718b8d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:07:20 +0900 Subject: [PATCH 04/32] style(zotero): format report roundtrip test --- .../tests/classification_report_roundtrip.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 1dd2aa78..db523ca7 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -1,6 +1,5 @@ use conceptweave_zotero::{ - ClassificationReport, ItemData, ZoteroItem, build_steward_review_worksheet, - classify_snapshot, + ClassificationReport, ItemData, ZoteroItem, build_steward_review_worksheet, classify_snapshot, }; #[test] From 9891f9b6c4911d63fcea0d1a3f7743f37846a8a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:07:52 +0900 Subject: [PATCH 05/32] test(zotero): preserve owned report revisions --- crates/conceptweave-zotero/tests/classification_rollback.rs | 2 +- .../conceptweave-zotero/tests/classification_write_plan.rs | 4 ++-- .../tests/duplicate_merge_review_components.rs | 6 +++--- .../tests/duplicate_merge_review_manifest.rs | 6 +++--- crates/conceptweave-zotero/tests/golden_set_evaluation.rs | 2 +- .../tests/golden_set_integrity_contract.rs | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_rollback.rs b/crates/conceptweave-zotero/tests/classification_rollback.rs index 33dde0d4..29b7bf58 100644 --- a/crates/conceptweave-zotero/tests/classification_rollback.rs +++ b/crates/conceptweave-zotero/tests/classification_rollback.rs @@ -52,7 +52,7 @@ fn plan() -> conceptweave_zotero::ClassificationWritePlan { 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(), snapshot_items: report.snapshot_items.clone(), changes: vec![ diff --git a/crates/conceptweave-zotero/tests/classification_write_plan.rs b/crates/conceptweave-zotero/tests/classification_write_plan.rs index 678e1457..053d665a 100644 --- a/crates/conceptweave-zotero/tests/classification_write_plan.rs +++ b/crates/conceptweave-zotero/tests/classification_write_plan.rs @@ -350,7 +350,7 @@ fn reviewed(report: &conceptweave_zotero::ClassificationReport) -> ReviewedClass 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(), snapshot_items: report.snapshot_items.clone(), changes: vec![ @@ -578,7 +578,7 @@ fn write_plan_fails_closed_for_untrusted_stale_or_unsafe_changes() { server_id: None, zotero_version: no_server.zotero_version.clone(), library_version: no_server.library_version, - rule_revision: no_server.rule_revision.into(), + rule_revision: no_server.rule_revision.clone(), snapshot_digest: no_server.snapshot_digest.clone(), snapshot_items: no_server.snapshot_items.clone(), changes: vec![ReviewedClassificationChange { diff --git a/crates/conceptweave-zotero/tests/duplicate_merge_review_components.rs b/crates/conceptweave-zotero/tests/duplicate_merge_review_components.rs index 4892fb01..aafa30ff 100644 --- a/crates/conceptweave-zotero/tests/duplicate_merge_review_components.rs +++ b/crates/conceptweave-zotero/tests/duplicate_merge_review_components.rs @@ -38,7 +38,7 @@ fn transitive_duplicate_component_accepts_one_component_level_canonical_key() { review_id: "review-transitive-component".into(), authority_receipt: "authority-transitive-component".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), @@ -94,7 +94,7 @@ fn duplicate_review_rejects_blank_snapshot_item_identity() { review_id: "review-blank-key".into(), authority_receipt: "authority-blank-key".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), @@ -135,7 +135,7 @@ fn duplicate_review_binds_every_item_revision_to_the_reviewed_snapshot() { review_id: "review-revisions".into(), authority_receipt: "authority-revisions".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), diff --git a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs index 868590f3..63136683 100644 --- a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs +++ b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs @@ -43,7 +43,7 @@ fn reviewed(report: &conceptweave_zotero::ClassificationReport) -> ReviewedDupli review_id: "review-duplicate-1".into(), authority_receipt: "authority-receipt-1".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), @@ -197,7 +197,7 @@ fn overlapping_duplicate_groups_require_one_consistent_canonical_choice() { review_id: "review-overlap".into(), authority_receipt: "authority-overlap".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), @@ -240,7 +240,7 @@ fn duplicate_review_rejects_ambiguous_snapshot_key_revisions() { review_id: "review-duplicate-key".into(), authority_receipt: "authority-duplicate-key".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), duplicate_candidates: report.duplicate_candidates.clone(), diff --git a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs index 43d9f216..e4fdc877 100644 --- a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs +++ b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs @@ -235,7 +235,7 @@ fn reviewed_golden_set_rejects_stale_unknown_and_duplicate_labels() { evaluate_reviewed_golden_set(&report, &stale, verify_synthetic_approval), Err(EvaluationError::SnapshotMismatch) ); - stale.approval.rule_revision = report.rule_revision.into(); + stale.approval.rule_revision = report.rule_revision.clone(); stale.approval.snapshot_items[0].item_version += 1; assert_eq!( evaluate_reviewed_golden_set(&report, &stale, verify_synthetic_approval), diff --git a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs index dc0615a0..8be3c184 100644 --- a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs +++ b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs @@ -44,7 +44,7 @@ fn approval( receipt_id: "approved-review".into(), reviewer_subject: "synthetic-steward".into(), library_version: report.library_version, - rule_revision: report.rule_revision.into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: classification_snapshot_digest(report), snapshot_items, } From 354ad8161321ca8b58921aaafb67f373204396f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:08:14 +0900 Subject: [PATCH 06/32] test(zotero): clear owned rule revisions --- crates/conceptweave-zotero/tests/steward_review_finalization.rs | 2 +- crates/conceptweave-zotero/tests/steward_review_worksheet.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/tests/steward_review_finalization.rs b/crates/conceptweave-zotero/tests/steward_review_finalization.rs index c368ca79..97670a34 100644 --- a/crates/conceptweave-zotero/tests/steward_review_finalization.rs +++ b/crates/conceptweave-zotero/tests/steward_review_finalization.rs @@ -76,7 +76,7 @@ fn finalization_rejects_each_invalid_identity_coordinate() { let worksheet = complete_worksheet(); let mut invalid_report = report(); - invalid_report.rule_revision = ""; + invalid_report.rule_revision.clear(); assert_eq!( reviewed_golden_set_from_worksheet(&invalid_report, &worksheet, approval(&worksheet)), Err(EvaluationError::InvalidReview) diff --git a/crates/conceptweave-zotero/tests/steward_review_worksheet.rs b/crates/conceptweave-zotero/tests/steward_review_worksheet.rs index 40fb2756..5b779eca 100644 --- a/crates/conceptweave-zotero/tests/steward_review_worksheet.rs +++ b/crates/conceptweave-zotero/tests/steward_review_worksheet.rs @@ -62,7 +62,7 @@ fn worksheet_is_snapshot_bound_complete_and_contains_no_bibliographic_text() { #[test] fn worksheet_rejects_each_inconsistent_report_coordinate() { let mut invalid = report(); - invalid.rule_revision = ""; + invalid.rule_revision.clear(); assert_eq!( build_steward_review_worksheet(&invalid), Err(WorksheetError::InvalidReport) From f185f5fbdd5921002d0cc4bfea00dd763d56eea8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:14:33 +0900 Subject: [PATCH 07/32] test(zotero): reject unbound restored child provenance --- .../tests/classification_report_roundtrip.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index db523ca7..6a2d5516 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -1,5 +1,6 @@ use conceptweave_zotero::{ - ClassificationReport, ItemData, ZoteroItem, build_steward_review_worksheet, classify_snapshot, + ClassificationReport, ItemData, WorksheetError, ZoteroItem, build_steward_review_worksheet, + classify_snapshot, }; #[test] @@ -34,3 +35,30 @@ fn owner_only_report_roundtrip_preserves_the_review_workload() { build_steward_review_worksheet(&original).unwrap() ); } + +#[test] +fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { + let item = ZoteroItem { + key: "ITEM".into(), + version: 7, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology alignment".into(), + abstract_note: "review context".into(), + doi: "10.1000/example".into(), + parent_item: String::new(), + collections: vec!["COLLECTION".into()], + tags: vec![], + }, + }; + let original = classify_snapshot("9.0.6".into(), None, 42, vec![item]); + let serialized = serde_json::to_vec(&original).unwrap(); + let mut restored: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + + restored.classified_items[0].child_item_keys = vec!["UNKNOWN_CHILD".into()]; + + assert_eq!( + build_steward_review_worksheet(&restored), + Err(WorksheetError::InvalidReport) + ); +} From 70369591dadf1c43df89e9ab54f8aac355232fa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:20:09 +0900 Subject: [PATCH 08/32] fix(zotero): bind restored child provenance --- crates/conceptweave-zotero/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index d6c1a53f..557f28c0 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1135,6 +1135,10 @@ pub fn build_steward_review_worksheet( for item in &report.classified_items { if !decision_keys.insert(item.item_key.as_str()) || snapshot_versions.get(item.item_key.as_str()) != Some(&item.item_version) + || item + .child_item_keys + .iter() + .any(|child_key| !snapshot_versions.contains_key(child_key.as_str())) || (item.proposed_disposition == Disposition::NeedsStewardReview) != item.abstention_reason.is_some() { From ae17deee09bf41a702f22ed0332961090177df5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:54:29 +0900 Subject: [PATCH 09/32] test(zotero): reject invalid restored child graphs --- .../tests/classification_report_roundtrip.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 6a2d5516..80f1c2e8 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -62,3 +62,55 @@ fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { Err(WorksheetError::InvalidReport) ); } + +#[test] +fn restored_report_rejects_classified_or_reused_child_provenance() { + let bibliographic = |key: &str| ZoteroItem { + key: key.into(), + version: 7, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology alignment".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }; + let child = ZoteroItem { + key: "CHILD".into(), + version: 3, + data: ItemData { + item_type: "note".into(), + title: String::new(), + abstract_note: String::new(), + doi: String::new(), + parent_item: "PARENT_A".into(), + collections: vec![], + tags: vec![], + }, + }; + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![bibliographic("PARENT_A"), bibliographic("PARENT_B"), child], + ); + let serialized = serde_json::to_vec(&report).unwrap(); + + let mut classified_child: ClassificationReport = + serde_json::from_slice(&serialized).unwrap(); + classified_child.classified_items[0].child_item_keys = vec!["PARENT_A".into()]; + assert_eq!( + build_steward_review_worksheet(&classified_child), + Err(WorksheetError::InvalidReport) + ); + + let mut reused_child: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + reused_child.classified_items[1].child_item_keys = vec!["CHILD".into()]; + assert_eq!( + build_steward_review_worksheet(&reused_child), + Err(WorksheetError::InvalidReport) + ); +} From 4bf657b2c2de258141b36553a523bfe4497e3d12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:55:30 +0900 Subject: [PATCH 10/32] fix(zotero): bind restored children to parents --- crates/conceptweave-zotero/src/lib.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 557f28c0..c3a80360 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1119,11 +1119,18 @@ pub fn build_steward_review_worksheet( return Err(WorksheetError::InvalidReport); } - let mut snapshot_versions = BTreeMap::new(); + let mut snapshot_coordinates = BTreeMap::new(); for item in &report.snapshot_items { if item.item_key.trim().is_empty() - || snapshot_versions - .insert(item.item_key.as_str(), item.item_version) + || item + .parent_item_key + .as_ref() + .is_some_and(|parent_key| parent_key.trim().is_empty()) + || snapshot_coordinates + .insert( + item.item_key.as_str(), + (item.item_version, item.parent_item_key.as_deref()), + ) .is_some() { return Err(WorksheetError::InvalidReport); @@ -1134,11 +1141,16 @@ pub fn build_steward_review_worksheet( let mut decisions = Vec::with_capacity(report.classified_items.len()); for item in &report.classified_items { if !decision_keys.insert(item.item_key.as_str()) - || snapshot_versions.get(item.item_key.as_str()) != Some(&item.item_version) + || snapshot_coordinates.get(item.item_key.as_str()) != Some(&(item.item_version, None)) || item .child_item_keys .iter() - .any(|child_key| !snapshot_versions.contains_key(child_key.as_str())) + .any(|child_key| { + !matches!( + snapshot_coordinates.get(child_key.as_str()), + Some((_, Some(parent_key))) if *parent_key == item.item_key + ) + }) || (item.proposed_disposition == Disposition::NeedsStewardReview) != item.abstention_reason.is_some() { @@ -1198,6 +1210,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, } /// Governance receipt binding a steward approval to one exact classifier input. @@ -2464,6 +2478,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_bytes = serde_json::to_vec(&items) From 7883614e8aef27bdfe805f3d43e1cbb27cbc2630 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:56:23 +0900 Subject: [PATCH 11/32] docs(zotero): preserve parent provenance coordinates --- crates/conceptweave-zotero/src/lib.rs | 1 + crates/conceptweave-zotero/tests/golden_set_evaluation.rs | 1 + .../tests/golden_set_integrity_contract.rs | 5 +++++ crates/conceptweave-zotero/tests/snapshot_content_binding.rs | 1 + docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 7 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index c3a80360..ecd0ae73 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1605,6 +1605,7 @@ where .map(|item_version| SnapshotItemRevision { item_key: (*item_key).clone(), item_version: *item_version, + parent_item_key: None, }) .ok_or(DuplicateReviewError::InvalidReview) }) diff --git a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs index e4fdc877..f62c8395 100644 --- a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs +++ b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs @@ -124,6 +124,7 @@ fn golden(labels: Vec) -> ReviewedGoldenSet { .map(|item_key| SnapshotItemRevision { item_key: item_key.into(), item_version: 1, + parent_item_key: None, }) .collect(), }, diff --git a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs index 8be3c184..23ae6c66 100644 --- a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs +++ b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs @@ -68,10 +68,12 @@ fn reviewed_snapshot_binding_includes_linked_child_revisions() { SnapshotItemRevision { item_key: "PARENT".into(), item_version: 7, + parent_item_key: None, }, SnapshotItemRevision { item_key: "NOTE1".into(), item_version: 3, + parent_item_key: Some("PARENT".into()), }, ], ), @@ -98,6 +100,7 @@ fn verified_snapshot_receipt_cannot_authorize_mutated_steward_labels() { vec![SnapshotItemRevision { item_key: "A".into(), item_version: 1, + parent_item_key: None, }], ), labels: vec![GoldenLabel::new("A", Disposition::AlignmentVersioning)], @@ -131,10 +134,12 @@ fn duplicate_zotero_keys_fail_closed_even_when_item_revisions_differ() { SnapshotItemRevision { item_key: "A".into(), item_version: 1, + parent_item_key: None, }, SnapshotItemRevision { item_key: "A".into(), item_version: 2, + parent_item_key: None, }, ], ), diff --git a/crates/conceptweave-zotero/tests/snapshot_content_binding.rs b/crates/conceptweave-zotero/tests/snapshot_content_binding.rs index efe76a1f..9f4c932a 100644 --- a/crates/conceptweave-zotero/tests/snapshot_content_binding.rs +++ b/crates/conceptweave-zotero/tests/snapshot_content_binding.rs @@ -33,6 +33,7 @@ fn golden_approval_rejects_same_revision_coordinates_with_changed_snapshot_conte snapshot_items: vec![SnapshotItemRevision { item_key: "A".into(), item_version: 1, + parent_item_key: None, }], }, labels: vec![GoldenLabel::new("A", Disposition::Generation)], diff --git a/docs/TRD.md b/docs/TRD.md index 741d1638..7001581d 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -68,7 +68,7 @@ Duplicate review is independent of subject classification. A reviewed decision s Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed before the external approval verifier is called, so an invalid local set cannot consume approval authority. The full-reclassification evaluator checks label cardinality before that boundary and additionally requires the reviewed label count to equal the unique classified bibliographic-item count; because the base evaluator rejects blank, duplicate, and unknown keys, equality proves complete coverage. A sampled golden set can measure quality but cannot satisfy this completion gate. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. -The review worksheet is a deterministic item-key-ordered projection of the report. `conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` reads one live snapshot and creates both owner-only outputs as a bound pair; failed output removes partial files so the same paths can be retried. It binds the library version, rule revision, raw-snapshot digest, complete parent/child item coordinates, item proposal, abstention reason, and an initially empty decision for every bibliographic item. Construction rejects blank or duplicate snapshot identity, mismatched item revisions, and inconsistent observed, bibliographic, proposal, provenance, abstention, duplicate, failure, or disposition counts. It deliberately omits bibliographic text and matched evidence; stewards consult the paired owner-only report by item key. +The review worksheet is a deterministic item-key-ordered projection of the report. `conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` reads one live snapshot and creates both owner-only outputs as a bound pair; failed output removes partial files so the same paths can be retried. It binds the library version, rule revision, raw-snapshot digest, complete item key/version/parent coordinates, item proposal, abstention reason, and an initially empty decision for every bibliographic item. Construction rejects blank or duplicate snapshot identity, mismatched item revisions, a classified item represented as a child, a child assigned to any parent other than its observed parent, and inconsistent observed, bibliographic, proposal, provenance, abstention, duplicate, failure, or disposition counts. It deliberately omits bibliographic text and matched evidence; stewards consult the paired owner-only report by item key. The finalization function consumes the filled worksheet plus a governance approval receipt. It rejects blank authority metadata, coordinate drift, duplicate or unknown decision keys, missing decisions, abstention as approved truth, and tampered proposal/reason pairs. Its output reuses the existing reviewed-golden-set evaluator; finalization itself does not verify external authority. The owner-only report uses owned JSON values and supports lossless deserialization. A serialize/deserialize roundtrip must preserve the report-derived worksheet exactly, allowing later offline finalization against the original snapshot rather than another live Zotero read. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 4f58c9f6..5c28961f 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -24,7 +24,7 @@ Duplicate candidates become canonical references only through externally verifie Classifier quality is measured only against local steward-reviewed labels whose reviewed set is verified outside this crate and bound to the exact library version, rule revision, canonical SHA-256 raw-snapshot digest, and every observed parent/child item-key/item-version coordinate. `NeedsStewardReview` is an abstention prediction and cannot be approved truth. Sampled labels may measure classifier quality, but a full-reclassification completion result requires exactly one approved label for every classified bibliographic item. Cardinality, snapshot, key, disposition, and duplicate checks run before the external approval verifier so invalid local input cannot consume approval authority. Evaluation returns the verified revisions and opaque digest with aggregate integer evidence; Zotero keys, reviewer identity, and bibliographic text are omitted. Missing, incomplete, stale, content- or label-mismatched, unverified, unknown, duplicate, or invalid review identities fail closed at the applicable completion boundary. Every successful report includes an aggregate audit summary computed from the same captured snapshot. Zotero 9 item version zero is preserved as a valid never-synced source coordinate, not treated as missing provenance. Partial reads never produce a report, so successful output explicitly records zero failures alongside snapshot, proposal, provenance, abstention, duplicate, and per-disposition totals. -To make full review executable without copying sensitive text again, ConceptWeave derives a local worksheet from the validated report. The worksheet carries the exact snapshot binding, complete parent/child revision coordinates, deterministic proposal and abstention reason, plus one empty steward-decision slot per bibliographic item in item-key order. Titles, abstracts, tags, collections, and matched evidence remain only in the owner-only report. Invalid report identity or coverage fails worksheet construction. +To make full review executable without copying sensitive text again, ConceptWeave derives a local worksheet from the validated report. The worksheet carries the exact snapshot binding, complete item key/version/parent coordinates, deterministic proposal and abstention reason, plus one empty steward-decision slot per bibliographic item in item-key order. Restored artifacts must preserve each observed child-to-parent relation; a classified item cannot masquerade as a child, and an in-snapshot child cannot be reassigned to another parent. Titles, abstracts, tags, collections, and matched evidence remain only in the owner-only report. Invalid report identity or coverage fails worksheet construction. The report is a losslessly deserializable owner-only artifact. Evidence field names, matched phrases, and the rule revision use owned values so an offline process can reconstruct the exact canonical worksheet from the saved report; it must not reread a mutable Zotero library to finalize an earlier review. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c5cca9c6..fb79d8da 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -50,7 +50,7 @@ The 3,658-item abstention queue now preserves each nonempty abstract exactly onc The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate with complete operation evidence retained separately and excluded from automatic retry until operator reconciliation. A later read-only reconciliation records the observed state, tolerates unrelated library-version advancement, and emits retry evidence only for an exact unchanged item; restoration metadata at a newer item revision proves current state but not causality. Reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. -The steward workload now has a deterministic local worksheet contract rather than an informal report-editing step. The existing CLI exports the report and worksheet from one live snapshot with `--worksheet`, preserving the owner-only file boundary and deleting incomplete output on failure. It binds library/rule/digest plus all parent and child revisions, emits one blank decision for each of the 3,715 bibliographic items in item-key order, and repeats only the proposal and abstention reason. Titles, abstracts, tags, collections, and matched evidence remain in the paired owner-only report. This stays inside ConceptWeave because no independent cross-product review utility contract exists. +The steward workload now has a deterministic local worksheet contract rather than an informal report-editing step. The existing CLI exports the report and worksheet from one live snapshot with `--worksheet`, preserving the owner-only file boundary and deleting incomplete output on failure. It binds library/rule/digest plus every item key/version/parent coordinate, rejects classified items used as children and in-snapshot children reassigned to another parent after report restoration, emits one blank decision for each of the 3,715 bibliographic items in item-key order, and repeats only the proposal and abstention reason. Titles, abstracts, tags, collections, and matched evidence remain in the paired owner-only report. This stays inside ConceptWeave because no independent cross-product review utility contract exists. Filled worksheets now have a fail-closed conversion into the existing reviewed golden-set contract. The conversion checks exact approval coordinates, complete unique decision identity, snapshot membership and item revision, proposal/abstention consistency, and rejects missing or abstention truth labels before the external authority verifier can run. The live completion KPI remains 0/3,715 until real steward decisions and an externally verified approval receipt exist. From 3cd868a2f265f0b9aaf71b2cd76414633cbe3312 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:56:29 +0900 Subject: [PATCH 12/32] style(zotero): format provenance validation --- crates/conceptweave-zotero/src/lib.rs | 15 ++++++--------- .../tests/classification_report_roundtrip.rs | 3 +-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index ecd0ae73..b6a64838 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1142,15 +1142,12 @@ pub fn build_steward_review_worksheet( for item in &report.classified_items { if !decision_keys.insert(item.item_key.as_str()) || snapshot_coordinates.get(item.item_key.as_str()) != Some(&(item.item_version, None)) - || item - .child_item_keys - .iter() - .any(|child_key| { - !matches!( - snapshot_coordinates.get(child_key.as_str()), - Some((_, Some(parent_key))) if *parent_key == item.item_key - ) - }) + || item.child_item_keys.iter().any(|child_key| { + !matches!( + snapshot_coordinates.get(child_key.as_str()), + Some((_, Some(parent_key))) if *parent_key == item.item_key + ) + }) || (item.proposed_disposition == Disposition::NeedsStewardReview) != item.abstention_reason.is_some() { diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 80f1c2e8..5d8a1410 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -99,8 +99,7 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { ); let serialized = serde_json::to_vec(&report).unwrap(); - let mut classified_child: ClassificationReport = - serde_json::from_slice(&serialized).unwrap(); + let mut classified_child: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); classified_child.classified_items[0].child_item_keys = vec!["PARENT_A".into()]; assert_eq!( build_steward_review_worksheet(&classified_child), From 0d8cced3ff1afc68906f93baebec2b7a31ae44f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:57:38 +0900 Subject: [PATCH 13/32] test(zotero): cover blank parent provenance --- .../tests/classification_report_roundtrip.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 5d8a1410..420f840a 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -61,6 +61,13 @@ fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { build_steward_review_worksheet(&restored), Err(WorksheetError::InvalidReport) ); + + let mut blank_parent: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + blank_parent.snapshot_items[0].parent_item_key = Some(" ".into()); + assert_eq!( + build_steward_review_worksheet(&blank_parent), + Err(WorksheetError::InvalidReport) + ); } #[test] From 7bc8765e9ced0f6db1e034a1ed346cbc390e5791 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:00:06 +0900 Subject: [PATCH 14/32] test(zotero): reject duplicate child sources --- .../tests/duplicate_merge_review_manifest.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs index 63136683..c877b267 100644 --- a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs +++ b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs @@ -2,6 +2,7 @@ use conceptweave_zotero::{ Disposition, DuplicateMergeDecision, DuplicateReviewError, ItemData, ReviewedDuplicateMergeSet, ZoteroItem, build_duplicate_merge_review_manifest, classify_snapshot, }; +use std::cell::Cell; fn item(key: &str, version: u64, title: &str, doi: &str) -> ZoteroItem { ZoteroItem { @@ -180,6 +181,28 @@ fn duplicate_review_contract_fails_closed() { } } +#[test] +fn duplicate_review_rejects_child_sources_before_approval_verification() { + let mut report = report(); + report.snapshot_items.push(conceptweave_zotero::SnapshotItemRevision { + item_key: "CHILD".into(), + item_version: 3, + parent_item_key: Some("A".into()), + }); + report.duplicate_candidates[0].item_keys[1] = "CHILD".into(); + let reviewed = reviewed(&report); + let verifier_calls = Cell::new(0); + + assert_eq!( + build_duplicate_merge_review_manifest(&report, &reviewed, |_| { + verifier_calls.set(verifier_calls.get() + 1); + true + }), + Err(DuplicateReviewError::InvalidReview) + ); + assert_eq!(verifier_calls.get(), 0); +} + #[test] fn overlapping_duplicate_groups_require_one_consistent_canonical_choice() { let report = classify_snapshot( From 92f44053bd229bdca37144129ee0a0a31ca275e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:01:43 +0900 Subject: [PATCH 15/32] fix(zotero): preserve duplicate source parent provenance --- crates/conceptweave-zotero/src/lib.rs | 29 ++++++++++++++----------- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 2 +- docs/product-technical-gap-baseline.md | 2 ++ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index b6a64838..a9d2a119 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1506,14 +1506,16 @@ where { return Err(DuplicateReviewError::SnapshotMismatch); } - if !verify_review(reviewed) { - return Err(DuplicateReviewError::UnverifiedApproval); - } - if report .snapshot_items .iter() - .any(|item| item.item_key.trim().is_empty()) + .any(|item| { + item.item_key.trim().is_empty() + || item + .parent_item_key + .as_ref() + .is_some_and(|parent_key| parent_key.trim().is_empty()) + }) || report .snapshot_items .iter() @@ -1524,10 +1526,10 @@ where { return 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::>(); let candidates = report .duplicate_candidates @@ -1597,13 +1599,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, - parent_item_key: None, - }) + .filter(|item| item.parent_item_key.is_none()) + .map(|item| (*item).clone()) .ok_or(DuplicateReviewError::InvalidReview) }) .collect::, _>>()?; @@ -1626,6 +1625,10 @@ 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)) diff --git a/docs/TRD.md b/docs/TRD.md index 7001581d..1adda782 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -63,7 +63,7 @@ Evaluation must separate extraction recall, semantic correctness, structural cor Every top-level bibliographic record receives exactly one proposed disposition. `NeedsStewardReview` also records a deterministic abstention reason so missing classification metadata, vocabulary unsupported by the current deterministic rules, and present-but-unmatched metadata are distinguishable. Its nonempty abstract is retained exactly once in the local report: an abstract that triggered conflicting rules remains in matched evidence, while other abstention abstracts use the review-only field. Non-abstained items omit that field. DOI duplicate identity normalization treats bare DOI values, `doi:`, `doi.org`, and legacy `dx.doi.org` resolver forms as the same identity when their normalized DOI is equal. -Duplicate review is independent of subject classification. A reviewed decision set must match the exact raw-snapshot digest and complete item-key/item-version coordinates, cover every duplicate candidate exactly once, select one retained key from the connected duplicate component, and pass an external governance verifier. Every operation records all component item revisions, identity mappings before and after canonicalization, and the exact rollback mapping. These mappings affect only downstream identity resolution; Zotero records are neither mutated nor deleted. +Duplicate review is independent of subject classification. A reviewed decision set must match the exact raw-snapshot digest and complete item key/version/parent coordinates, cover every duplicate candidate exactly once, select one retained top-level key from the connected duplicate component, and pass an external governance verifier. Local identity, parent, component, and retained-key validation finishes before that verifier is invoked. Every operation preserves the full source coordinates, identity mappings before and after canonicalization, and the exact rollback mapping. These mappings affect only downstream identity resolution; Zotero records are neither mutated nor deleted. Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The digest covers every raw Zotero item in canonical key order. Blank, duplicate, unknown, stale, content-mismatched, label-mismatched, or abstention-as-truth inputs fail closed before the external approval verifier is called, so an invalid local set cannot consume approval authority. The full-reclassification evaluator checks label cardinality before that boundary and additionally requires the reviewed label count to equal the unique classified bibliographic-item count; because the base evaluator rejects blank, duplicate, and unknown keys, equality proves complete coverage. A sampled golden set can measure quality but cannot satisfy this completion gate. The output retains the verified library version, rule revision, and opaque snapshot digest, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels to that boundary instead of minting authority. A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 5c28961f..9c1ea279 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -19,7 +19,7 @@ The adapter links child records, emits exactly one deterministic proposed dispos Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. On supported Unix platforms, the sensitive local report is created with exact owner-only `0600` permissions after applying the process umask; other platforms fail closed. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. -Duplicate candidates become canonical references only through externally verified steward decisions bound to the raw digest, complete item-key/item-version snapshot, and exact candidate membership. Overlapping candidates form one connected component and must select one component-level canonical item. Every resulting operation retains all component source revisions and complete before/after/rollback key mappings. It changes downstream identity resolution only; classification does not merge, delete, or mutate Zotero source records. +Duplicate candidates become canonical references only through externally verified steward decisions bound to the raw digest, complete item key/version/parent snapshot, and exact candidate membership. Duplicate sources must be observed top-level records, and local parent, component, and retained-key validation finishes before approval verification. Overlapping candidates form one connected component and must select one component-level canonical item. Every resulting operation preserves all component source coordinates and complete before/after/rollback key mappings. It changes downstream identity resolution only; classification does not merge, delete, or mutate Zotero source records. Classifier quality is measured only against local steward-reviewed labels whose reviewed set is verified outside this crate and bound to the exact library version, rule revision, canonical SHA-256 raw-snapshot digest, and every observed parent/child item-key/item-version coordinate. `NeedsStewardReview` is an abstention prediction and cannot be approved truth. Sampled labels may measure classifier quality, but a full-reclassification completion result requires exactly one approved label for every classified bibliographic item. Cardinality, snapshot, key, disposition, and duplicate checks run before the external approval verifier so invalid local input cannot consume approval authority. Evaluation returns the verified revisions and opaque digest with aggregate integer evidence; Zotero keys, reviewer identity, and bibliographic text are omitted. Missing, incomplete, stale, content- or label-mismatched, unverified, unknown, duplicate, or invalid review identities fail closed at the applicable completion boundary. Every successful report includes an aggregate audit summary computed from the same captured snapshot. Zotero 9 item version zero is preserved as a valid never-synced source coordinate, not treated as missing provenance. Partial reads never produce a report, so successful output explicitly records zero failures alongside snapshot, proposal, provenance, abstention, duplicate, and per-disposition totals. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fb79d8da..fe9da50e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -52,6 +52,8 @@ The golden-set evaluation contract now records aggregate precision/recall numera The steward workload now has a deterministic local worksheet contract rather than an informal report-editing step. The existing CLI exports the report and worksheet from one live snapshot with `--worksheet`, preserving the owner-only file boundary and deleting incomplete output on failure. It binds library/rule/digest plus every item key/version/parent coordinate, rejects classified items used as children and in-snapshot children reassigned to another parent after report restoration, emits one blank decision for each of the 3,715 bibliographic items in item-key order, and repeats only the proposal and abstention reason. Titles, abstracts, tags, collections, and matched evidence remain in the paired owner-only report. This stays inside ConceptWeave because no independent cross-product review utility contract exists. +The same parent coordinate is authoritative for duplicate review. Restored duplicate candidates may reference only observed top-level records; local snapshot identity, parent, component, and retained-key validation completes before the external verifier, and each manifest operation preserves the original source coordinates instead of fabricating top-level provenance. + Filled worksheets now have a fail-closed conversion into the existing reviewed golden-set contract. The conversion checks exact approval coordinates, complete unique decision identity, snapshot membership and item revision, proposal/abstention consistency, and rejects missing or abstention truth labels before the external authority verifier can run. The live completion KPI remains 0/3,715 until real steward decisions and an externally verified approval receipt exist. The paired owner-only report is now losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. This removes the need to reread a mutable Zotero library when the completed worksheet is later finalized. A CLI import/finalization command remains the next operator-facing Gap. From 5065119e0f0757167225ec736d0cd3f15bdf0ed2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:01:49 +0900 Subject: [PATCH 16/32] style(zotero): format duplicate provenance checks --- crates/conceptweave-zotero/src/lib.rs | 26 ++++++++----------- .../tests/duplicate_merge_review_manifest.rs | 12 +++++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index a9d2a119..6e8c801a 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1506,23 +1506,19 @@ where { return Err(DuplicateReviewError::SnapshotMismatch); } - if report + if report.snapshot_items.iter().any(|item| { + item.item_key.trim().is_empty() + || item + .parent_item_key + .as_ref() + .is_some_and(|parent_key| parent_key.trim().is_empty()) + }) || report .snapshot_items .iter() - .any(|item| { - item.item_key.trim().is_empty() - || item - .parent_item_key - .as_ref() - .is_some_and(|parent_key| parent_key.trim().is_empty()) - }) - || report - .snapshot_items - .iter() - .map(|item| item.item_key.as_str()) - .collect::>() - .len() - != report.snapshot_items.len() + .map(|item| item.item_key.as_str()) + .collect::>() + .len() + != report.snapshot_items.len() { return Err(DuplicateReviewError::InvalidReview); } diff --git a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs index c877b267..ea5b612d 100644 --- a/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs +++ b/crates/conceptweave-zotero/tests/duplicate_merge_review_manifest.rs @@ -184,11 +184,13 @@ fn duplicate_review_contract_fails_closed() { #[test] fn duplicate_review_rejects_child_sources_before_approval_verification() { let mut report = report(); - report.snapshot_items.push(conceptweave_zotero::SnapshotItemRevision { - item_key: "CHILD".into(), - item_version: 3, - parent_item_key: Some("A".into()), - }); + report + .snapshot_items + .push(conceptweave_zotero::SnapshotItemRevision { + item_key: "CHILD".into(), + item_version: 3, + parent_item_key: Some("A".into()), + }); report.duplicate_candidates[0].item_keys[1] = "CHILD".into(); let reviewed = reviewed(&report); let verifier_calls = Cell::new(0); From 0ff44f9d95260b014be501f23da58cd5a7a4819d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:04:11 +0900 Subject: [PATCH 17/32] test(zotero): reject incomplete child provenance --- .../tests/classification_report_roundtrip.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 420f840a..315756b9 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -119,4 +119,20 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { build_steward_review_worksheet(&reused_child), Err(WorksheetError::InvalidReport) ); + + let mut omitted_child: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + omitted_child.classified_items[0].child_item_keys.clear(); + assert_eq!( + build_steward_review_worksheet(&omitted_child), + Err(WorksheetError::InvalidReport) + ); + + let mut duplicate_child: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + duplicate_child.classified_items[0] + .child_item_keys + .push("CHILD".into()); + assert_eq!( + build_steward_review_worksheet(&duplicate_child), + Err(WorksheetError::InvalidReport) + ); } From afc93a5dd17fcbfddd7bc9ba093531572a2cbb1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:04:39 +0900 Subject: [PATCH 18/32] fix(zotero): require complete child provenance --- crates/conceptweave-zotero/src/lib.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 6e8c801a..583c5aa6 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1120,6 +1120,7 @@ pub fn build_steward_review_worksheet( } let mut snapshot_coordinates = BTreeMap::new(); + let mut expected_child_keys: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); for item in &report.snapshot_items { if item.item_key.trim().is_empty() || item @@ -1135,19 +1136,29 @@ pub fn build_steward_review_worksheet( { return Err(WorksheetError::InvalidReport); } + if let Some(parent_key) = item.parent_item_key.as_deref() { + expected_child_keys + .entry(parent_key) + .or_default() + .insert(item.item_key.as_str()); + } } let mut decision_keys = BTreeSet::new(); let mut decisions = Vec::with_capacity(report.classified_items.len()); for item in &report.classified_items { + let actual_child_keys: BTreeSet<&str> = item + .child_item_keys + .iter() + .map(String::as_str) + .collect(); if !decision_keys.insert(item.item_key.as_str()) || snapshot_coordinates.get(item.item_key.as_str()) != Some(&(item.item_version, None)) - || item.child_item_keys.iter().any(|child_key| { - !matches!( - snapshot_coordinates.get(child_key.as_str()), - Some((_, Some(parent_key))) if *parent_key == item.item_key - ) - }) + || actual_child_keys.len() != item.child_item_keys.len() + || expected_child_keys + .remove(item.item_key.as_str()) + .unwrap_or_default() + != actual_child_keys || (item.proposed_disposition == Disposition::NeedsStewardReview) != item.abstention_reason.is_some() { @@ -1161,6 +1172,9 @@ pub fn build_steward_review_worksheet( reviewed_disposition: None, }); } + if !expected_child_keys.is_empty() { + return Err(WorksheetError::InvalidReport); + } decisions.sort_by(|left, right| left.item_key.cmp(&right.item_key)); Ok(StewardReviewWorksheet { From c901b9740c0da58e42a8a43851757f2541214c22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:05:00 +0900 Subject: [PATCH 19/32] style(zotero): format child provenance check --- crates/conceptweave-zotero/src/lib.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 583c5aa6..b6d6ac70 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1147,11 +1147,8 @@ pub fn build_steward_review_worksheet( let mut decision_keys = BTreeSet::new(); let mut decisions = Vec::with_capacity(report.classified_items.len()); for item in &report.classified_items { - let actual_child_keys: BTreeSet<&str> = item - .child_item_keys - .iter() - .map(String::as_str) - .collect(); + let actual_child_keys: BTreeSet<&str> = + item.child_item_keys.iter().map(String::as_str).collect(); if !decision_keys.insert(item.item_key.as_str()) || snapshot_coordinates.get(item.item_key.as_str()) != Some(&(item.item_version, None)) || actual_child_keys.len() != item.child_item_keys.len() From c1138cd62ebaf6169c978518592994fc2905294c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:05:52 +0900 Subject: [PATCH 20/32] test(zotero): reject orphaned child provenance --- .../tests/classification_report_roundtrip.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 315756b9..45658016 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -135,4 +135,17 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { build_steward_review_worksheet(&duplicate_child), Err(WorksheetError::InvalidReport) ); + + let mut orphaned_child: ClassificationReport = serde_json::from_slice(&serialized).unwrap(); + orphaned_child.classified_items[0].child_item_keys.clear(); + orphaned_child + .snapshot_items + .iter_mut() + .find(|item| item.item_key == "CHILD") + .unwrap() + .parent_item_key = Some("UNCLASSIFIED_PARENT".into()); + assert_eq!( + build_steward_review_worksheet(&orphaned_child), + Err(WorksheetError::InvalidReport) + ); } From ff88db44ef07f9c5d4559a97791314f7f0d25e4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:07:35 +0900 Subject: [PATCH 21/32] test(zotero): preserve nested child provenance --- .../tests/classification_report_roundtrip.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 45658016..e435636f 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -149,3 +149,32 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { Err(WorksheetError::InvalidReport) ); } + +#[test] +fn restored_report_accepts_snapshot_bound_nested_child_provenance() { + let nested_item = |key: &str, item_type: &str, parent_item: &str| ZoteroItem { + key: key.into(), + version: 7, + data: ItemData { + item_type: item_type.into(), + title: (key == "BOOK").then_some("ontology alignment").unwrap_or_default().into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: parent_item.into(), + collections: vec![], + tags: vec![], + }, + }; + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + nested_item("BOOK", "book", ""), + nested_item("ATTACHMENT", "attachment", "BOOK"), + nested_item("ANNOTATION", "annotation", "ATTACHMENT"), + ], + ); + + assert!(build_steward_review_worksheet(&report).is_ok()); +} From b4e4a883a468864682618554f7bca85712c5447f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:07:53 +0900 Subject: [PATCH 22/32] fix(zotero): allow nested child provenance --- crates/conceptweave-zotero/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index b6d6ac70..80948152 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1143,6 +1143,11 @@ pub fn build_steward_review_worksheet( .insert(item.item_key.as_str()); } } + if snapshot_coordinates.values().any(|(_, parent_key)| { + parent_key.is_some_and(|parent_key| !snapshot_coordinates.contains_key(parent_key)) + }) { + return Err(WorksheetError::InvalidReport); + } let mut decision_keys = BTreeSet::new(); let mut decisions = Vec::with_capacity(report.classified_items.len()); @@ -1169,9 +1174,6 @@ pub fn build_steward_review_worksheet( reviewed_disposition: None, }); } - if !expected_child_keys.is_empty() { - return Err(WorksheetError::InvalidReport); - } decisions.sort_by(|left, right| left.item_key.cmp(&right.item_key)); Ok(StewardReviewWorksheet { From 4992eb5cb3210f6fefaf52da25a719f65fc23385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:08:09 +0900 Subject: [PATCH 23/32] style(zotero): format nested provenance fixture --- .../tests/classification_report_roundtrip.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index e435636f..81f6ac7a 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -157,7 +157,10 @@ fn restored_report_accepts_snapshot_bound_nested_child_provenance() { version: 7, data: ItemData { item_type: item_type.into(), - title: (key == "BOOK").then_some("ontology alignment").unwrap_or_default().into(), + title: (key == "BOOK") + .then_some("ontology alignment") + .unwrap_or_default() + .into(), abstract_note: String::new(), doi: String::new(), parent_item: parent_item.into(), From 194e84dd1c694d17d24e5a90496ef50634b3cc90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:08:41 +0900 Subject: [PATCH 24/32] test(zotero): clarify nested fixture title --- .../tests/classification_report_roundtrip.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 81f6ac7a..26a4d1f3 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -157,10 +157,12 @@ fn restored_report_accepts_snapshot_bound_nested_child_provenance() { version: 7, data: ItemData { item_type: item_type.into(), - title: (key == "BOOK") - .then_some("ontology alignment") - .unwrap_or_default() - .into(), + title: if key == "BOOK" { + "ontology alignment" + } else { + "" + } + .into(), abstract_note: String::new(), doi: String::new(), parent_item: parent_item.into(), From 25a787a201bfa4e5c71a78d35ab79e0ab857d354 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:04:17 +0900 Subject: [PATCH 25/32] test(research): reject imported proposal mutation under original approval Signed-off-by: Seongho Bae --- .../tests/classification_report_roundtrip.rs | 74 ++++++++++++++++++- docs/TRD.md | 2 +- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 26a4d1f3..781a2c03 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -1,11 +1,14 @@ use conceptweave_zotero::{ - ClassificationReport, ItemData, WorksheetError, ZoteroItem, build_steward_review_worksheet, - classify_snapshot, + ClassificationReport, Disposition, EvaluationError, GoldenSetApproval, ItemData, + ReviewedGoldenSet, WorksheetError, ZoteroItem, build_steward_review_worksheet, + classification_proposal_digest, classify_snapshot, evaluate_complete_reviewed_classification, + reviewed_golden_set_from_worksheet, }; #[test] fn owner_only_report_roundtrip_preserves_the_review_workload() { let mut item = ZoteroItem { + source_record: None, key: "ITEM".into(), version: 7, data: ItemData { @@ -34,11 +37,75 @@ fn owner_only_report_roundtrip_preserves_the_review_workload() { build_steward_review_worksheet(&restored).unwrap(), build_steward_review_worksheet(&original).unwrap() ); + assert_eq!( + classification_proposal_digest(&restored), + classification_proposal_digest(&original) + ); + + let mut worksheet = build_steward_review_worksheet(&original).unwrap(); + worksheet.decisions[0].reviewed_disposition = Some(Disposition::AlignmentVersioning); + let approval = GoldenSetApproval { + receipt_id: "synthetic-roundtrip-receipt".into(), + reviewer_subject: "synthetic-steward".into(), + library_version: original.library_version, + rule_revision: original.rule_revision.clone(), + snapshot_digest: original.snapshot_digest.clone(), + proposal_digest: classification_proposal_digest(&original), + snapshot_items: original.snapshot_items.clone(), + }; + let golden = reviewed_golden_set_from_worksheet(&original, &worksheet, approval).unwrap(); + let restored_golden: ReviewedGoldenSet = + serde_json::from_slice(&serde_json::to_vec(&golden).unwrap()).unwrap(); + assert_eq!( + evaluate_complete_reviewed_classification(&restored, &restored_golden, |candidate| { + candidate == &golden + }) + .unwrap() + .correct_count, + 1 + ); + + for alter_title in [false, true] { + let mut changed_json = serde_json::to_value(&original).unwrap(); + if alter_title { + changed_json["classified_items"][0]["title"] = "changed after review".into(); + } else { + changed_json["classified_items"][0]["evidence"]["field_values"] = serde_json::json!({}); + } + let changed_report: ClassificationReport = serde_json::from_value(changed_json).unwrap(); + assert_eq!(changed_report.snapshot_digest, original.snapshot_digest); + assert_eq!( + build_steward_review_worksheet(&changed_report).unwrap(), + build_steward_review_worksheet(&original).unwrap() + ); + assert_eq!( + reviewed_golden_set_from_worksheet( + &changed_report, + &worksheet, + restored_golden.approval.clone() + ), + Err(EvaluationError::SnapshotMismatch) + ); + let verifier_calls = std::cell::Cell::new(0); + assert_eq!( + evaluate_complete_reviewed_classification( + &changed_report, + &restored_golden, + |candidate| { + verifier_calls.set(verifier_calls.get() + 1); + candidate == &golden + } + ), + Err(EvaluationError::SnapshotMismatch) + ); + assert_eq!(verifier_calls.get(), 0); + } } #[test] fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { let item = ZoteroItem { + source_record: None, key: "ITEM".into(), version: 7, data: ItemData { @@ -73,6 +140,7 @@ fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { #[test] fn restored_report_rejects_classified_or_reused_child_provenance() { let bibliographic = |key: &str| ZoteroItem { + source_record: None, key: key.into(), version: 7, data: ItemData { @@ -86,6 +154,7 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { }, }; let child = ZoteroItem { + source_record: None, key: "CHILD".into(), version: 3, data: ItemData { @@ -153,6 +222,7 @@ fn restored_report_rejects_classified_or_reused_child_provenance() { #[test] fn restored_report_accepts_snapshot_bound_nested_child_provenance() { let nested_item = |key: &str, item_type: &str, parent_item: &str| ZoteroItem { + source_record: None, key: key.into(), version: 7, data: ItemData { diff --git a/docs/TRD.md b/docs/TRD.md index 85ee0c63..028b6cb0 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -76,7 +76,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The review worksheet is a deterministic item-key-ordered projection of the report. `conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` reads one live snapshot and creates both owner-only outputs as a bound pair; failed output removes partial files so the same paths can be retried. It binds the library version, rule revision, raw-snapshot digest, complete item key/version/parent coordinates, item proposal, abstention reason, and an initially empty decision for every bibliographic item. Construction rejects blank or duplicate snapshot identity, mismatched item revisions, a classified item represented as a child, a child assigned to any parent other than its observed parent, and inconsistent observed, bibliographic, proposal, provenance, abstention, duplicate, failure, or disposition counts. It deliberately omits bibliographic text and matched evidence; stewards consult the paired owner-only report by item key. The finalization function consumes the filled worksheet plus a governance approval receipt. It rejects blank authority metadata, missing or mismatched proposal bindings, coordinate drift, duplicate or unknown decision keys, missing decisions, abstention as approved truth, and tampered proposal/reason pairs. It recomputes the complete current proposal digest, including title and evidence fields omitted from the worksheet, and compares it with the supplied approval without replacing that approval. Its output reuses the existing reviewed-golden-set evaluator; finalization itself does not verify external authority. -The owner-only report uses owned JSON values and supports lossless deserialization. A serialize/deserialize roundtrip must preserve the report-derived worksheet exactly, allowing later offline finalization against the original snapshot rather than another live Zotero read. +The owner-only report uses owned JSON values and supports lossless deserialization. A serialize/deserialize roundtrip must preserve the report-derived worksheet and proposal digest exactly, allowing later offline finalization against the original snapshot rather than another live Zotero read. Deserialization establishes structure, not approval: changing serialized title or evidence under an unchanged source digest must still fail both finalization and evaluation against the original receipt before external verification. The local report can contain titles, tags, matched metadata, and abstention abstracts. It is sensitive steward-review material, remains outside the repository, and is not a publication artifact. From 5c95bb77ac12d25477ef278f7a23976700ceac2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 21:49:59 +0900 Subject: [PATCH 26/32] test(zotero): preserve owned rule revision in original receipt fixture Restore the clone-only intent of 1ca8a798b6a183c3dd823f416e396d6a4bb6149a. PR #28 merge 1623aadcb3680490c477b263681c6aa066dc3b5c retained an unnecessary into() after clone(); current PR #29 inherits that strict-Clippy failure. Later descendants already keep the clone-only expression. No receipt evidence or behavior changes. (cherry picked from commit 79facfc875dfe72c78680d98fce89b813bc216a1) --- .../tests/classification_write_receipt_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs index fcb97d15..d35f844e 100644 --- a/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs +++ b/crates/conceptweave-zotero/tests/classification_write_receipt_contract.rs @@ -56,7 +56,7 @@ fn reviewed(report: &conceptweave_zotero::ClassificationReport) -> ReviewedClass server_id: report.server_id.clone(), zotero_version: report.zotero_version.clone(), library_version: report.library_version, - rule_revision: report.rule_revision.clone().into(), + rule_revision: report.rule_revision.clone(), snapshot_digest: report.snapshot_digest.clone(), snapshot_items: report.snapshot_items.clone(), changes: vec![ From e606c0fb44c5e5c247cd2ae0b6b05aadf047acbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:20:42 +0900 Subject: [PATCH 27/32] fix(research): preserve owned revision values in inherited test receipts --- crates/conceptweave-zotero/src/lib.rs | 2 +- crates/conceptweave-zotero/src/tests/authenticated_transport.rs | 2 +- crates/conceptweave-zotero/tests/steward_review_context.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index a4b74850..a39892f7 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -3238,7 +3238,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(), diff --git a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs index d17be9ed..369b4fbe 100644 --- a/crates/conceptweave-zotero/src/tests/authenticated_transport.rs +++ b/crates/conceptweave-zotero/src/tests/authenticated_transport.rs @@ -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(), diff --git a/crates/conceptweave-zotero/tests/steward_review_context.rs b/crates/conceptweave-zotero/tests/steward_review_context.rs index 73c27a94..0edcc30e 100644 --- a/crates/conceptweave-zotero/tests/steward_review_context.rs +++ b/crates/conceptweave-zotero/tests/steward_review_context.rs @@ -134,7 +134,7 @@ fn changed_review_context_invalidates_prior_approval_before_verification() { receipt_id: "synthetic-receipt".into(), reviewer_subject: "synthetic-steward".into(), 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(), From 97fc4907ae4bbfd1211366eacf30a3152c2b1616 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:21:36 +0900 Subject: [PATCH 28/32] test(research): reproduce restored parent-coordinate admission gaps --- .../tests/classification_report_roundtrip.rs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs index 781a2c03..ee0b7419 100644 --- a/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs +++ b/crates/conceptweave-zotero/tests/classification_report_roundtrip.rs @@ -74,7 +74,7 @@ fn owner_only_report_roundtrip_preserves_the_review_workload() { } let changed_report: ClassificationReport = serde_json::from_value(changed_json).unwrap(); assert_eq!(changed_report.snapshot_digest, original.snapshot_digest); - assert_eq!( + assert_ne!( build_steward_review_worksheet(&changed_report).unwrap(), build_steward_review_worksheet(&original).unwrap() ); @@ -102,6 +102,64 @@ fn owner_only_report_roundtrip_preserves_the_review_workload() { } } +#[test] +fn shared_report_validation_binds_every_retained_parent_coordinate() { + for parent_key in ["A", "missing", "source", ""] { + let items = [("A", "book", ""), ("source", "attachment", parent_key)] + .into_iter() + .map(|(key, item_type, parent)| { + serde_json::from_value::(serde_json::json!({ + "key": key, "version": 1, + "data": {"itemType": item_type, "parentItem": parent, "title": "synthetic"} + })) + .unwrap() + }) + .collect(); + let mut report = classify_snapshot("9.0.6".into(), None, 42, items); + assert!(conceptweave_zotero::validate_classification_report(&report).is_ok()); + report + .snapshot_items + .iter_mut() + .find(|item| item.item_key == "source") + .unwrap() + .parent_item_key = Some("different-parent".into()); + assert_eq!( + conceptweave_zotero::validate_classification_report(&report), + Err(EvaluationError::InvalidReview) + ); + } +} + +#[test] +fn pending_metadata_roundtrip_preserves_review_identity_not_raw_capture() { + for parent_key in ["missing", "source", ""] { + let item: ZoteroItem = serde_json::from_value(serde_json::json!({ + "key": "source", "version": 1, "unknown_provider_field": "raw-only-sentinel", + "data": {"itemType": "attachment", "parentItem": parent_key, "title": "synthetic"} + })) + .unwrap(); + let original = classify_snapshot("9.0.6".into(), None, 42, vec![item]); + let bytes = serde_json::to_vec(&original).unwrap(); + assert!( + !String::from_utf8(bytes.clone()) + .unwrap() + .contains("raw-only-sentinel") + ); + let restored: ClassificationReport = serde_json::from_slice(&bytes).unwrap(); + let restored_again: ClassificationReport = + serde_json::from_slice(&serde_json::to_vec(&restored).unwrap()).unwrap(); + assert_eq!(serde_json::to_vec(&restored_again).unwrap(), bytes); + assert_eq!( + classification_proposal_digest(&original), + classification_proposal_digest(&restored_again) + ); + assert_eq!( + build_steward_review_worksheet(&original).unwrap(), + build_steward_review_worksheet(&restored_again).unwrap() + ); + } +} + #[test] fn restored_report_rejects_child_provenance_outside_the_bound_snapshot() { let item = ZoteroItem { From 1157b1df0c6fc8bcce7d11657e6d51b8c1bcf499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:22:20 +0900 Subject: [PATCH 29/32] fix(research): bind parent coordinates in shared report admission --- crates/conceptweave-zotero/src/lib.rs | 59 +++++++-------------------- 1 file changed, 14 insertions(+), 45 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index a39892f7..6b546b30 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1166,50 +1166,10 @@ pub fn build_steward_review_worksheet( return Err(WorksheetError::InvalidReport); } - let mut snapshot_coordinates = BTreeMap::new(); - let mut expected_child_keys: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); - for item in &report.snapshot_items { - if item.item_key.trim().is_empty() - || item - .parent_item_key - .as_ref() - .is_some_and(|parent_key| parent_key.trim().is_empty()) - || snapshot_coordinates - .insert( - item.item_key.as_str(), - (item.item_version, item.parent_item_key.as_deref()), - ) - .is_some() - { - return Err(WorksheetError::InvalidReport); - } - if let Some(parent_key) = item.parent_item_key.as_deref() { - expected_child_keys - .entry(parent_key) - .or_default() - .insert(item.item_key.as_str()); - } - } - if snapshot_coordinates.values().any(|(_, parent_key)| { - parent_key.is_some_and(|parent_key| !snapshot_coordinates.contains_key(parent_key)) - }) { - return Err(WorksheetError::InvalidReport); - } - - let mut decision_keys = BTreeSet::new(); let mut decisions = Vec::with_capacity(report.classified_items.len()); for item in &report.classified_items { - let actual_child_keys: BTreeSet<&str> = - item.child_item_keys.iter().map(String::as_str).collect(); - if !decision_keys.insert(item.item_key.as_str()) - || snapshot_coordinates.get(item.item_key.as_str()) != Some(&(item.item_version, None)) - || actual_child_keys.len() != item.child_item_keys.len() - || expected_child_keys - .remove(item.item_key.as_str()) - .unwrap_or_default() - != actual_child_keys - || (item.proposed_disposition == Disposition::NeedsStewardReview) - != item.abstention_reason.is_some() + if (item.proposed_disposition == Disposition::NeedsStewardReview) + != item.abstention_reason.is_some() { return Err(WorksheetError::InvalidReport); } @@ -1508,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); @@ -1526,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); } From e102894ea45b01e38bb198b21ed5ecf791df6d82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:28:01 +0900 Subject: [PATCH 30/32] fix(zotero): preserve shared parent validation precedence --- crates/conceptweave-zotero/src/lib.rs | 12 ------------ .../tests/golden_set_integrity_contract.rs | 6 ++++++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 6b546b30..b8d68e3e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1584,18 +1584,6 @@ where .iter() .map(|item| (item.item_key.as_str(), item.proposed_disposition)) .collect::>(); - if classified.len() != report.classified_items.len() - || report.classified_items.iter().any(|item| { - item.item_key.trim().is_empty() - || !report_snapshot.contains(&SnapshotItemRevision { - item_key: item.item_key.clone(), - item_version: item.item_version, - parent_item_key: None, - }) - }) - { - return Err(EvaluationError::InvalidReview); - } if golden.approval.proposal_digest != classification_proposal_digest(report) { return Err(EvaluationError::SnapshotMismatch); } diff --git a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs index 78ba9287..e63fe6b4 100644 --- a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs +++ b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs @@ -552,6 +552,12 @@ fn source_metadata_mutations_invalidate_the_original_approval_before_verificatio "type" => source.data.item_type = "attachment".into(), "parent" => { source.data.parent_item = "C".into(); + report + .snapshot_items + .iter_mut() + .find(|item| item.item_key == source.key) + .unwrap() + .parent_item_key = Some("C".into()); report.pending_source_item_keys = vec!["T".into()]; } _ => unreachable!(), From 29cc97af7670b3c8bbe184a7bd98a815d42f2db7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:28:41 +0900 Subject: [PATCH 31/32] docs(zotero): distinguish metadata restoration from raw capture --- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index 8760422f..5b6c4bdc 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -114,7 +114,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi The review worksheet is a deterministic item-key-ordered projection of the report. It binds library/rule revisions, raw-snapshot digest, required `proposal_digest` from the existing v2 scope hash, complete item coordinates, proposal, abstention reason, and one initially empty decision per bibliographic item. Construction reuses `validate_classification_report`, mapped to `WorksheetError::InvalidReport`, before worksheet-specific nonblank identity and abstention checks. This avoids a second drifting audit implementation while admitting structurally valid pending source evidence. It deliberately omits bibliographic text and matched evidence; stewards consult the owner-only report by item key. Missing proposal binding fails deserialization. Subsequent progress, application and finalization owners must compare this field to the recomputed report binding; present-but-blank or rewritten values are not authority. Legacy worksheets must be regenerated, and independent approval remains separate. The finalization function consumes the filled worksheet plus a governance approval receipt. It rejects blank authority metadata, missing or mismatched proposal bindings, coordinate drift, duplicate or unknown decision keys, missing decisions, abstention as approved truth, and tampered proposal/reason pairs. It recomputes the complete current proposal digest, including title and evidence fields omitted from the worksheet, and compares it with the supplied approval without replacing that approval. Its output reuses the existing reviewed-golden-set evaluator; finalization itself does not verify external authority. It also rejects a blank worksheet `proposal_digest` as `InvalidReview` and compares that field with the freshly built expected worksheet as `SnapshotMismatch`, retaining existing cardinality and approval error precedence. The converter does not accept an updated receipt as proof that old decisions reviewed changed content. A caller can construct self-consistent unverified data, so the evaluator still authenticates the entire reviewed set against independent evidence. Pending sources are admitted for preparation but rejected by complete evaluation before governance is contacted. Later extracted worksheet validators must preserve this comparison. -The owner-only report uses owned JSON values and supports lossless deserialization. A serialize/deserialize roundtrip must preserve the report-derived worksheet and proposal digest exactly, allowing later offline finalization against the original snapshot rather than another live Zotero read. Deserialization establishes structure, not approval: changing serialized title or evidence under an unchanged source digest must still fail both finalization and evaluation against the original receipt before external verification. +The owner-only report uses owned JSON values and supports lossless deserialization of its defined metadata projection, not the original provider record or full-text capture. Unknown provider fields and raw capture bytes are not serialized; retain captures separately. Repeated serialize/deserialize roundtrips preserve report bytes, the report-derived worksheet and proposal digest, allowing offline finalization against the stored snapshot identity rather than another live Zotero read. Shared report validation binds every retained key, version and parent coordinate; classified sources are top-level, while valid unresolved orphan and cyclic metadata remains pending. Deserialization establishes structure, not approval: changing serialized title or evidence under an unchanged source digest must still fail both finalization and evaluation against the original receipt before external verification. The local report can contain titles, tags, matched metadata, and abstention abstracts. It is sensitive steward-review material, remains outside the repository, and is not a publication artifact. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 69ecdc26..6aa2f86a 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -30,7 +30,13 @@ Every successful report includes an aggregate audit summary computed from the sa To make full review executable without copying sensitive text again, ConceptWeave derives a local worksheet from the validated report. The worksheet carries the exact snapshot binding, complete item key/version/parent coordinates, deterministic proposal and abstention reason, plus one empty steward-decision slot per bibliographic item in item-key order. Restored artifacts must preserve each observed child-to-parent relation; a classified item cannot masquerade as a child, and an in-snapshot child cannot be reassigned to another parent. Titles, abstracts, tags, collections, and matched evidence remain only in the owner-only report. Invalid report identity or coverage fails worksheet construction. -The report is a losslessly deserializable owner-only artifact. Evidence field names, matched phrases, and the rule revision use owned values so an offline process can reconstruct the exact canonical worksheet from the saved report; it must not reread a mutable Zotero library to finalize an earlier review. +The report is a losslessly deserializable owner-only metadata projection, not a raw-source backup. Evidence field names, matched phrases, and the rule revision use owned values so an offline process can reconstruct the exact canonical worksheet from the saved report; it must not reread a mutable Zotero library to finalize an earlier review. + +### Proposed parent-coordinate continuity amendment — September 7 + +PR28 adds parent coordinates and offline report restoration on top of the repaired source-scope and worksheet contracts. Shared validation previously checked only key/version and could accept a changed retained parent; the worksheet's duplicate validation instead rejected legitimate pending orphan evidence. RED `97fc490` reproduces both failures. `1157b1d` compares key/version/parent once in the shared validator and removes the divergent worksheet checks. `e102894` removes the now-redundant evaluator guard and retains stale-approval rejection for structurally coherent metadata mutations. Classified records require no parent; retained unclassified metadata must match its explicit parent coordinate. Missing or cyclic ancestry remains pending, never automatically classified or approved. + +We rejected a second worksheet-only validator because sibling evaluation and duplicate admission would remain inconsistent. We rejected storing raw capture fields in this metadata artifact because it would broaden the private data surface and conflate full-text capture with review projection. Unit tests verify repeated serialization, proposal digest and worksheet identity while proving an unknown raw field stays excluded. The trade-off is intentional: reconstructed metadata cannot replace the original capture or independently recompute its raw-source digest. Later consumers must inherit this contract and keep capture verification and approval separate. This amendment remains Proposed pending protected review and release. Proposed worksheet source-scope amendment: in the context of continuing review across saved local worksheets, facing removed retained records or changed context at unchanged item revisions, we decided for the existing shared report validator and required v2 proposal digest, and against a duplicate worksheet audit or coordinate-only identity, to preserve complete source scope and distinguish changed review material, accepting a breaking serialized-field requirement and downstream comparison work. RED `900038e` reproduces omitted inventory and hidden pending keys; `5b54d06` removes the divergent checks in favor of the shared validator. RED `30aa091` reproduces equal worksheets for changed content and successful loading without binding; `97046c7` reuses the existing scope hash. Valid standalone, orphan and cyclic evidence still permits blank worksheet creation, because preparation is not completion. No source metadata is copied into a new artifact. From 63eb0f116408372675f132b9836fe7be4bdd7134 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:29:20 +0900 Subject: [PATCH 32/32] docs(zotero): record roundtrip source scope evidence --- docs/product-technical-gap-baseline.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 864e14c3..a695640e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -6,6 +6,14 @@ This file records code-current product and technical gaps. Exact PR/check/run co ## September 6 source inventory checkpoint +### September 7 PR28 metadata roundtrip continuity repair + +Normal merge `13b5529` preserves original PR28 `ba6b3dfc71cf89ed4c57b85da0dd9ca5f983efee` and PR27 `fd5ef23c1c23fa36ef106400e15c9438eaa5cd41`. RED `97fc490` compiled with four passing and two failing roundtrip tests: inconsistent retained parent coordinates passed shared admission, while valid pending orphan metadata failed worksheet restoration. `1157b1d` centralizes parent validation and removes divergent worksheet checks; `e102894` removes an implied evaluator guard and preserves coherent-mutation stale-approval rejection. Independent read-only review found no further production defect in the bounded repair. + +Source `e102894` passes 179 tests/26 suites including three doctests, strict all-target Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged coverage gate passes 299/299 functions, 2,642/2,642 normalized regions and 470/470 normalized branches. Raw coverage remains 3,449/3,511 lines, 5,245/5,352 regions and 425/470 branches, not 100%. Logs: `/tmp/conceptweave-pr28-{parent-red,final-tests,clippy,rustdoc,coverage}.log`. Documentation amendment `29cc97a` distinguishes the restored metadata projection from raw source/full-text capture. Unknown provider fields stay excluded; no new capture mechanism or dependency was added. + +Root and later consumers have not yet inherited these repairs. Authentic decisions and independent approvals remain 0/3,715, with four unresolved standalone sources. Synthetic fixtures are not paper review evidence. Native Zotero Visual Inspection was retried but the Mac is locked; no new screen evidence exists. No real Zotero write, hosted GREEN, protected approval/merge or release is claimed. Preserve Draft and continue normal successor integration with fresh exact-head evidence. + ### September 7 PR27 finalization continuity repair Final source `da2556b` passes 172 tests/25 suites including three doctests, strict all-target Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged pinned coverage gate passes 295/295 functions, 2,611/2,611 normalized regions and 468/468 normalized branches. Raw coverage remains 3,436/3,498 lines, 5,214/5,321 regions and 423/468 branches, not 100%. Logs: `/tmp/conceptweave-pr27-verified.log` and `/tmp/conceptweave-pr27-{clippy,rustdoc,coverage}-verified.log`. Both baseline and integration finished before their successors were edited; the final documentation commit receives its own full verification.