From 70ddedce91429a6a954595badcf9d6719c0fecff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:45:41 +0900 Subject: [PATCH 01/21] test(zotero): require explicit worksheet CLI mode --- crates/conceptweave-zotero/src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index d8601264..b9f6d0a4 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -113,6 +113,22 @@ fn main() -> Result<(), Box> { mod tests { use super::*; + #[test] + fn worksheet_mode_is_explicit_and_rejects_ambiguous_arguments() { + let output = "/tmp/conceptweave-zotero-worksheet.json"; + assert_eq!( + parse_output_request(vec!["--worksheet", output]), + Ok((true, output.to_owned())) + ); + assert_eq!( + parse_output_request(vec![output]), + Ok((false, output.to_owned())) + ); + assert!(parse_output_request(Vec::<&str>::new()).is_err()); + assert!(parse_output_request(vec!["--worksheet"]).is_err()); + assert!(parse_output_request(vec![output, "extra"]).is_err()); + } + fn unique_temp_path(suffix: &str) -> PathBuf { env::temp_dir().join(format!( "conceptweave-zotero-{}-{suffix}.json", From 86b5d8142649a8036b9b9071cc9503482beb35cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:46:12 +0900 Subject: [PATCH 02/21] feat(zotero): export steward worksheet from CLI --- crates/conceptweave-zotero/src/main.rs | 36 ++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index b9f6d0a4..385804b2 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -1,12 +1,36 @@ #![forbid(unsafe_code)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -use conceptweave_zotero::read_local_snapshot; +use conceptweave_zotero::{build_steward_review_worksheet, read_local_snapshot}; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; +fn parse_output_request(args: I) -> Result<(bool, String), &'static str> +where + I: IntoIterator, + S: Into, +{ + let mut args = args.into_iter().map(Into::into); + let first = args + .next() + .ok_or("usage: conceptweave-zotero [--worksheet] /tmp/OUTPUT.json")?; + let (worksheet, output) = if first == "--worksheet" { + ( + true, + args.next() + .ok_or("--worksheet requires an output path")?, + ) + } else { + (false, first) + }; + if args.next().is_some() { + return Err("unexpected extra argument"); + } + Ok((worksheet, output)) +} + #[cfg_attr(coverage_nightly, coverage(off))] /// Returns canonical directories in which a sensitive report may be created. fn allowed_output_parents() -> Vec { @@ -94,9 +118,7 @@ fn create_report_file_with( #[cfg_attr(coverage_nightly, coverage(off))] /// Reads one Zotero snapshot and writes its sensitive local proposal report. fn main() -> Result<(), Box> { - let output = env::args() - .nth(1) - .ok_or("usage: conceptweave-zotero /tmp/OUTPUT.json")?; + let (worksheet, output) = parse_output_request(env::args().skip(1))?; let output = validate_output_path(&output)?; let report = read_local_snapshot()?; if report.zotero_version.starts_with("9.") { @@ -104,7 +126,11 @@ fn main() -> Result<(), Box> { } let file = create_report_file(&output)?; let mut writer = BufWriter::new(file); - serde_json::to_writer_pretty(&mut writer, &report)?; + if worksheet { + serde_json::to_writer_pretty(&mut writer, &build_steward_review_worksheet(&report)?)?; + } else { + serde_json::to_writer_pretty(&mut writer, &report)?; + } writer.flush()?; Ok(()) } From 571aeece85e0f91b8939494eed9da8df21251b03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:46:43 +0900 Subject: [PATCH 03/21] docs(zotero): document live worksheet export --- CHANGELOG.md | 1 + docs/TRD.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd95ab0..7be1e970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to ConceptWeave are documented here. - Owner-only file permissions for sensitive local Zotero classification reports. - A complete-review evaluator that rejects partial steward labels as full reclassification evidence. - 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. ### Security diff --git a/docs/TRD.md b/docs/TRD.md index 966ce0c6..8b6e891a 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. 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, or proposal counts. It deliberately omits bibliographic text and matched evidence; stewards consult the owner-only report by item key. +The review worksheet is a deterministic item-key-ordered projection of the report. `conceptweave-zotero --worksheet /tmp/OUTPUT.json` reads one live snapshot and creates it with the same owner-only output protections as the report. 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 owner-only report by item key. 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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f2dd0b03..50cbd72c 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. 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 separate 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 it with `--worksheet`, preserving the owner-only file boundary. 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 separate owner-only report. This stays inside ConceptWeave because no independent cross-product review utility contract exists. 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. From 76abb734a821dfe41bfe57e34969b3b26cfe7e54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:47:08 +0900 Subject: [PATCH 04/21] fix(zotero): describe both local output modes --- crates/conceptweave-zotero/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 385804b2..648d3434 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -122,7 +122,7 @@ fn main() -> Result<(), Box> { let output = validate_output_path(&output)?; let report = read_local_snapshot()?; if report.zotero_version.starts_with("9.") { - eprintln!("Zotero 9 Local API is read-only; writing a local proposal report only"); + eprintln!("Zotero 9 Local API is read-only; writing local proposal output only"); } let file = create_report_file(&output)?; let mut writer = BufWriter::new(file); From efed098a08abc9d0805ff49564dd7b02e062fcc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:47:18 +0900 Subject: [PATCH 05/21] style(zotero): format CLI parser --- crates/conceptweave-zotero/src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 648d3434..19323d42 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -19,8 +19,7 @@ where let (worksheet, output) = if first == "--worksheet" { ( true, - args.next() - .ok_or("--worksheet requires an output path")?, + args.next().ok_or("--worksheet requires an output path")?, ) } else { (false, first) From 4e2047e60096b60b619824357a622cfaaafde2ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:48:41 +0900 Subject: [PATCH 06/21] test(zotero): require one-snapshot review artifact pair --- crates/conceptweave-zotero/src/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 19323d42..75ff791e 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -140,18 +140,21 @@ mod tests { #[test] fn worksheet_mode_is_explicit_and_rejects_ambiguous_arguments() { - let output = "/tmp/conceptweave-zotero-worksheet.json"; + let report = "/tmp/conceptweave-zotero-report.json"; + let worksheet = "/tmp/conceptweave-zotero-worksheet.json"; assert_eq!( - parse_output_request(vec!["--worksheet", output]), - Ok((true, output.to_owned())) + parse_output_request(vec!["--worksheet", report, worksheet]), + Ok((Some(report.to_owned()), worksheet.to_owned())) ); assert_eq!( - parse_output_request(vec![output]), - Ok((false, output.to_owned())) + parse_output_request(vec![report]), + Ok((None, report.to_owned())) ); assert!(parse_output_request(Vec::<&str>::new()).is_err()); assert!(parse_output_request(vec!["--worksheet"]).is_err()); - assert!(parse_output_request(vec![output, "extra"]).is_err()); + assert!(parse_output_request(vec!["--worksheet", report]).is_err()); + assert!(parse_output_request(vec![report, "extra"]).is_err()); + assert!(parse_output_request(vec!["--worksheet", report, report]).is_err()); } fn unique_temp_path(suffix: &str) -> PathBuf { From e75ee517808d7d3f5c11a23bfb8f2f70764322fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:49:24 +0900 Subject: [PATCH 07/21] fix(zotero): bind and clean review artifact pair --- crates/conceptweave-zotero/src/main.rs | 77 +++++++++++++++++++++----- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 75ff791e..231a15de 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -3,11 +3,12 @@ use conceptweave_zotero::{build_steward_review_worksheet, read_local_snapshot}; use std::env; +use std::error::Error; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; -fn parse_output_request(args: I) -> Result<(bool, String), &'static str> +fn parse_output_request(args: I) -> Result<(Option, String), &'static str> where I: IntoIterator, S: Into, @@ -16,18 +17,34 @@ where let first = args .next() .ok_or("usage: conceptweave-zotero [--worksheet] /tmp/OUTPUT.json")?; - let (worksheet, output) = if first == "--worksheet" { - ( - true, - args.next().ok_or("--worksheet requires an output path")?, - ) + let request = if first == "--worksheet" { + let report = args.next().ok_or("--worksheet requires report and worksheet output paths")?; + let worksheet = args.next().ok_or("--worksheet requires report and worksheet output paths")?; + if report == worksheet { + return Err("report and worksheet output paths must differ"); + } + (Some(report), worksheet) } else { - (false, first) + (None, first) }; if args.next().is_some() { return Err("unexpected extra argument"); } - Ok((worksheet, output)) + Ok(request) +} + +fn write_private_output( + path: &Path, + write: impl FnOnce(&mut BufWriter) -> Result<(), Box>, +) -> Result<(), Box> { + let file = create_report_file(path)?; + let mut writer = BufWriter::new(file); + if let Err(error) = write(&mut writer).and_then(|()| writer.flush().map_err(Into::into)) { + drop(writer); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(()) } #[cfg_attr(coverage_nightly, coverage(off))] @@ -117,20 +134,35 @@ fn create_report_file_with( #[cfg_attr(coverage_nightly, coverage(off))] /// Reads one Zotero snapshot and writes its sensitive local proposal report. fn main() -> Result<(), Box> { - let (worksheet, output) = parse_output_request(env::args().skip(1))?; + let (report_output, output) = parse_output_request(env::args().skip(1))?; let output = validate_output_path(&output)?; + let report_output = report_output + .as_deref() + .map(validate_output_path) + .transpose()?; let report = read_local_snapshot()?; if report.zotero_version.starts_with("9.") { eprintln!("Zotero 9 Local API is read-only; writing local proposal output only"); } - let file = create_report_file(&output)?; - let mut writer = BufWriter::new(file); - if worksheet { - serde_json::to_writer_pretty(&mut writer, &build_steward_review_worksheet(&report)?)?; + if let Some(report_output) = report_output { + let worksheet = build_steward_review_worksheet(&report)?; + write_private_output(&report_output, |writer| { + serde_json::to_writer_pretty(writer, &report)?; + Ok(()) + })?; + if let Err(error) = write_private_output(&output, |writer| { + serde_json::to_writer_pretty(writer, &worksheet)?; + Ok(()) + }) { + let _ = fs::remove_file(report_output); + return Err(error); + } } else { - serde_json::to_writer_pretty(&mut writer, &report)?; + write_private_output(&output, |writer| { + serde_json::to_writer_pretty(writer, &report)?; + Ok(()) + })?; } - writer.flush()?; Ok(()) } @@ -157,6 +189,21 @@ mod tests { assert!(parse_output_request(vec!["--worksheet", report, report]).is_err()); } + #[test] + fn failed_private_output_is_removed_for_retry() { + let output = unique_temp_path("failed-output"); + let _ = fs::remove_file(&output); + let error = write_private_output(&output, |_| { + Err(io::Error::new(io::ErrorKind::WriteZero, "injected write failure").into()) + }) + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + io::ErrorKind::WriteZero + ); + assert!(!output.exists()); + } + fn unique_temp_path(suffix: &str) -> PathBuf { env::temp_dir().join(format!( "conceptweave-zotero-{}-{suffix}.json", From 03566363d62ecbd85ad94fd170f39ec7a3e118f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:49:43 +0900 Subject: [PATCH 08/21] docs(zotero): require paired review outputs --- docs/TRD.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index 8b6e891a..8727a212 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/OUTPUT.json` reads one live snapshot and creates it with the same owner-only output protections as the report. 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 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 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 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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 50cbd72c..3e92da7e 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 it with `--worksheet`, preserving the owner-only file boundary. 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 separate 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 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 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. From 63d1913342315b2408899494a7700f5d93cffb48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:49:55 +0900 Subject: [PATCH 09/21] style(zotero): format paired output parser --- crates/conceptweave-zotero/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 231a15de..baac9d0b 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -18,8 +18,12 @@ where .next() .ok_or("usage: conceptweave-zotero [--worksheet] /tmp/OUTPUT.json")?; let request = if first == "--worksheet" { - let report = args.next().ok_or("--worksheet requires report and worksheet output paths")?; - let worksheet = args.next().ok_or("--worksheet requires report and worksheet output paths")?; + let report = args + .next() + .ok_or("--worksheet requires report and worksheet output paths")?; + let worksheet = args + .next() + .ok_or("--worksheet requires report and worksheet output paths")?; if report == worksheet { return Err("report and worksheet output paths must differ"); } From 44dc487e39ba117eab656035d3eb9fda55890513 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:50:46 +0900 Subject: [PATCH 10/21] refactor(zotero): serialize review outputs before creation --- crates/conceptweave-zotero/src/main.rs | 48 ++++++++++++++------------ 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index baac9d0b..3c4bde37 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -3,7 +3,6 @@ use conceptweave_zotero::{build_steward_review_worksheet, read_local_snapshot}; use std::env; -use std::error::Error; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; @@ -39,11 +38,22 @@ where fn write_private_output( path: &Path, - write: impl FnOnce(&mut BufWriter) -> Result<(), Box>, -) -> Result<(), Box> { + content: &[u8], +) -> io::Result<()> { + write_private_output_with(path, content, |writer, bytes| { + writer.write_all(bytes)?; + writer.flush() + }) +} + +fn write_private_output_with( + path: &Path, + content: &[u8], + write: fn(&mut BufWriter, &[u8]) -> io::Result<()>, +) -> io::Result<()> { let file = create_report_file(path)?; let mut writer = BufWriter::new(file); - if let Err(error) = write(&mut writer).and_then(|()| writer.flush().map_err(Into::into)) { + if let Err(error) = write(&mut writer, content) { drop(writer); let _ = fs::remove_file(path); return Err(error); @@ -150,22 +160,15 @@ fn main() -> Result<(), Box> { } if let Some(report_output) = report_output { let worksheet = build_steward_review_worksheet(&report)?; - write_private_output(&report_output, |writer| { - serde_json::to_writer_pretty(writer, &report)?; - Ok(()) - })?; - if let Err(error) = write_private_output(&output, |writer| { - serde_json::to_writer_pretty(writer, &worksheet)?; - Ok(()) - }) { + let report_content = serde_json::to_vec_pretty(&report)?; + let worksheet_content = serde_json::to_vec_pretty(&worksheet)?; + write_private_output(&report_output, &report_content)?; + if let Err(error) = write_private_output(&output, &worksheet_content) { let _ = fs::remove_file(report_output); return Err(error); } } else { - write_private_output(&output, |writer| { - serde_json::to_writer_pretty(writer, &report)?; - Ok(()) - })?; + write_private_output(&output, &serde_json::to_vec_pretty(&report)?)?; } Ok(()) } @@ -197,15 +200,16 @@ mod tests { fn failed_private_output_is_removed_for_retry() { let output = unique_temp_path("failed-output"); let _ = fs::remove_file(&output); - let error = write_private_output(&output, |_| { - Err(io::Error::new(io::ErrorKind::WriteZero, "injected write failure").into()) + let error = write_private_output_with(&output, b"content", |_, _| { + Err(io::Error::new(io::ErrorKind::WriteZero, "injected write failure")) }) .unwrap_err(); - assert_eq!( - error.downcast_ref::().unwrap().kind(), - io::ErrorKind::WriteZero - ); + assert_eq!(error.kind(), io::ErrorKind::WriteZero); assert!(!output.exists()); + + write_private_output(&output, b"complete").unwrap(); + assert_eq!(fs::read(&output).unwrap(), b"complete"); + fs::remove_file(output).unwrap(); } fn unique_temp_path(suffix: &str) -> PathBuf { From 92d67ea63ff7f13185fea36c61041b5e30bf75d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:50:58 +0900 Subject: [PATCH 11/21] style(zotero): format private output helper --- crates/conceptweave-zotero/src/main.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 3c4bde37..ac40cc83 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -36,10 +36,7 @@ where Ok(request) } -fn write_private_output( - path: &Path, - content: &[u8], -) -> io::Result<()> { +fn write_private_output(path: &Path, content: &[u8]) -> io::Result<()> { write_private_output_with(path, content, |writer, bytes| { writer.write_all(bytes)?; writer.flush() @@ -201,7 +198,10 @@ mod tests { let output = unique_temp_path("failed-output"); let _ = fs::remove_file(&output); let error = write_private_output_with(&output, b"content", |_, _| { - Err(io::Error::new(io::ErrorKind::WriteZero, "injected write failure")) + Err(io::Error::new( + io::ErrorKind::WriteZero, + "injected write failure", + )) }) .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::WriteZero); From ec94738fcd901c96797f61b02ab4978b8d553b10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:51:15 +0900 Subject: [PATCH 12/21] fix(zotero): propagate paired output errors --- crates/conceptweave-zotero/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index ac40cc83..fdaef6eb 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -162,7 +162,7 @@ fn main() -> Result<(), Box> { write_private_output(&report_output, &report_content)?; if let Err(error) = write_private_output(&output, &worksheet_content) { let _ = fs::remove_file(report_output); - return Err(error); + return Err(error.into()); } } else { write_private_output(&output, &serde_json::to_vec_pretty(&report)?)?; From 382da03aea594bd4b85c2f2768ebae5795ff385c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:52:03 +0900 Subject: [PATCH 13/21] test(zotero): cover private output creation failure --- crates/conceptweave-zotero/src/main.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index fdaef6eb..c3018b52 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -37,10 +37,13 @@ where } fn write_private_output(path: &Path, content: &[u8]) -> io::Result<()> { - write_private_output_with(path, content, |writer, bytes| { - writer.write_all(bytes)?; - writer.flush() - }) + write_private_output_with(path, content, write_all_and_flush) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn write_all_and_flush(writer: &mut BufWriter, content: &[u8]) -> io::Result<()> { + writer.write_all(content)?; + writer.flush() } fn write_private_output_with( @@ -209,6 +212,7 @@ mod tests { write_private_output(&output, b"complete").unwrap(); assert_eq!(fs::read(&output).unwrap(), b"complete"); + assert!(write_private_output(&output, b"replacement").is_err()); fs::remove_file(output).unwrap(); } From dc60e6a8121aa10de1c451453baba564cfd8b7e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:53:26 +0900 Subject: [PATCH 14/21] fix(zotero): document paired CLI usage --- crates/conceptweave-zotero/src/main.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index c3018b52..ecb12e64 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -7,15 +7,16 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; +const USAGE: &str = + "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json"; + fn parse_output_request(args: I) -> Result<(Option, String), &'static str> where I: IntoIterator, S: Into, { let mut args = args.into_iter().map(Into::into); - let first = args - .next() - .ok_or("usage: conceptweave-zotero [--worksheet] /tmp/OUTPUT.json")?; + let first = args.next().ok_or(USAGE)?; let request = if first == "--worksheet" { let report = args .next() @@ -189,7 +190,7 @@ mod tests { parse_output_request(vec![report]), Ok((None, report.to_owned())) ); - assert!(parse_output_request(Vec::<&str>::new()).is_err()); + assert_eq!(parse_output_request(Vec::<&str>::new()), Err(USAGE)); assert!(parse_output_request(vec!["--worksheet"]).is_err()); assert!(parse_output_request(vec!["--worksheet", report]).is_err()); assert!(parse_output_request(vec![report, "extra"]).is_err()); From 2efa1ab55ffc018301b3995de85d577d0840e310 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:53:30 +0900 Subject: [PATCH 15/21] style(zotero): format CLI usage --- crates/conceptweave-zotero/src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index ecb12e64..102a879a 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -7,8 +7,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; -const USAGE: &str = - "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json"; +const USAGE: &str = "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json"; fn parse_output_request(args: I) -> Result<(Option, String), &'static str> where From 68575a63bd550ed419e3657e6f8c97563c0adbaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:57:43 +0900 Subject: [PATCH 16/21] test(research): reproduce output unlink and implicit flush races --- crates/conceptweave-zotero/src/main.rs | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 674022de..c800a959 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -199,6 +199,51 @@ mod tests { assert!(parse_output_request(vec!["--worksheet", report, report]).is_err()); } + #[test] + fn write_failure_preserves_replacement_at_output_path() { + let output = unique_temp_path("write-race"); + let retained = unique_temp_path("write-race-retained"); + assert!(!output.exists()); + assert!(!retained.exists()); + let error = write_private_output_with(&output, b"content", |_, _| { + let output = unique_temp_path("write-race"); + fs::rename(&output, unique_temp_path("write-race-retained"))?; + let mut replacement = OpenOptions::new() + .write(true) + .create_new(true) + .open(output)?; + replacement.write_all(b"unrelated replacement")?; + Err(io::Error::new(io::ErrorKind::WriteZero, "injected failure")) + }) + .unwrap_err(); + let replacement = fs::read(&output); + let _ = fs::remove_file(output); + fs::remove_file(retained).unwrap(); + assert_eq!(error.kind(), io::ErrorKind::WriteZero); + assert_eq!(replacement.unwrap(), b"unrelated replacement"); + } + + #[test] + fn write_failure_does_not_retry_buffered_bytes_on_drop() { + let output = unique_temp_path("write-buffer"); + let retained = unique_temp_path("write-buffer-retained"); + assert!(!output.exists()); + assert!(!retained.exists()); + let error = write_private_output_with(&output, b"buffered content", |writer, content| { + fs::rename( + unique_temp_path("write-buffer"), + unique_temp_path("write-buffer-retained"), + )?; + writer.write_all(content)?; + Err(io::Error::new(io::ErrorKind::WriteZero, "injected failure")) + }) + .unwrap_err(); + let retained_bytes = fs::read(&retained).unwrap(); + fs::remove_file(retained).unwrap(); + assert_eq!(error.kind(), io::ErrorKind::WriteZero); + assert!(retained_bytes.is_empty()); + } + #[test] fn failed_private_output_is_removed_for_retry() { let output = unique_temp_path("failed-output"); From ab391b26206a0cf5aee02c005080fb8967907b02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:58:29 +0900 Subject: [PATCH 17/21] fix(research): preserve failed outputs without implicit write retries --- crates/conceptweave-zotero/src/main.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index c800a959..ed5df8bf 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -53,12 +53,10 @@ fn write_private_output_with( ) -> io::Result<()> { let file = create_report_file(path)?; let mut writer = BufWriter::new(file); - if let Err(error) = write(&mut writer, content) { - drop(writer); - let _ = fs::remove_file(path); - return Err(error); - } - Ok(()) + let result = write(&mut writer, content); + // Never retry buffered writes on drop or unlink a possibly replaced pathname. + let _ = writer.into_parts(); + result } #[cfg_attr(coverage_nightly, coverage(off))] @@ -166,10 +164,7 @@ fn main() -> Result<(), Box> { let report_content = serde_json::to_vec_pretty(&report)?; let worksheet_content = serde_json::to_vec_pretty(&worksheet)?; write_private_output(&report_output, &report_content)?; - if let Err(error) = write_private_output(&output, &worksheet_content) { - let _ = fs::remove_file(report_output); - return Err(error.into()); - } + write_private_output(&output, &worksheet_content)?; } else { write_private_output(&output, &serde_json::to_vec_pretty(&report)?)?; } @@ -245,7 +240,7 @@ mod tests { } #[test] - fn failed_private_output_is_removed_for_retry() { + fn failed_private_output_is_preserved_and_requires_a_new_path() { let output = unique_temp_path("failed-output"); let _ = fs::remove_file(&output); let error = write_private_output_with(&output, b"content", |_, _| { @@ -256,7 +251,9 @@ mod tests { }) .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::WriteZero); - assert!(!output.exists()); + assert_eq!(fs::metadata(&output).unwrap().len(), 0); + assert!(write_private_output(&output, b"retry").is_err()); + fs::remove_file(&output).unwrap(); write_private_output(&output, b"complete").unwrap(); assert_eq!(fs::read(&output).unwrap(), b"complete"); From fb5b5e52f5eb9a0653933b42a4958529966a983a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:59:45 +0900 Subject: [PATCH 18/21] test(research): expose canonical output alias collision before reads --- crates/conceptweave-zotero/src/main.rs | 37 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index ed5df8bf..f8322304 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -36,6 +36,15 @@ where Ok(request) } +fn validate_output_request( + report_output: Option<&str>, + output: &str, +) -> io::Result<(Option, PathBuf)> { + let output = validate_output_path(output)?; + let report_output = report_output.map(validate_output_path).transpose()?; + Ok((report_output, output)) +} + fn write_private_output(path: &Path, content: &[u8]) -> io::Result<()> { write_private_output_with(path, content, write_all_and_flush) } @@ -150,11 +159,7 @@ fn create_report_file_with( /// Reads one Zotero snapshot and writes its sensitive local proposal report. fn main() -> Result<(), Box> { let (report_output, output) = parse_output_request(env::args().skip(1))?; - let output = validate_output_path(&output)?; - let report_output = report_output - .as_deref() - .map(validate_output_path) - .transpose()?; + let (report_output, output) = validate_output_request(report_output.as_deref(), &output)?; let report = read_local_snapshot()?; if report.zotero_version.starts_with("9.") { eprintln!("Zotero 9 Local API is read-only; writing local proposal output only"); @@ -194,6 +199,28 @@ mod tests { assert!(parse_output_request(vec!["--worksheet", report, report]).is_err()); } + #[test] + fn canonical_output_aliases_are_rejected_without_creating_files() { + let output = + Path::new("/tmp").join(format!("conceptweave-alias-{}.json", std::process::id())); + let canonical = output + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(output.file_name().unwrap()); + let result = + validate_output_request(Some(output.to_str().unwrap()), canonical.to_str().unwrap()); + assert!(!output.exists()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); + let other = unique_temp_path("other-output"); + assert!( + validate_output_request(Some(output.to_str().unwrap()), other.to_str().unwrap()) + .is_ok() + ); + assert!(validate_output_request(None, output.to_str().unwrap()).is_ok()); + } + #[test] fn write_failure_preserves_replacement_at_output_path() { let output = unique_temp_path("write-race"); From e928858965c206a6046d0614da6fecb2818bfe9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:00:09 +0900 Subject: [PATCH 19/21] fix(research): reject canonical artifact aliases before snapshot read --- crates/conceptweave-zotero/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index f8322304..765f0c4e 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -42,6 +42,12 @@ fn validate_output_request( ) -> io::Result<(Option, PathBuf)> { let output = validate_output_path(output)?; let report_output = report_output.map(validate_output_path).transpose()?; + if report_output.as_ref() == Some(&output) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "report and worksheet output paths must differ", + )); + } Ok((report_output, output)) } From 58ff5985890d5a0b4aaadaa1f8d604e1bc96a1e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:02:14 +0900 Subject: [PATCH 20/21] test(research): cover both output admission failures --- crates/conceptweave-zotero/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 765f0c4e..6f3825c1 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -225,6 +225,10 @@ mod tests { .is_ok() ); assert!(validate_output_request(None, output.to_str().unwrap()).is_ok()); + assert!(validate_output_request(None, "relative-output").is_err()); + assert!( + validate_output_request(Some("relative-report"), output.to_str().unwrap()).is_err() + ); } #[test] From 7ad6386de13e19bb57fc4519141ac67c7b8bf92b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:04:06 +0900 Subject: [PATCH 21/21] docs(research): record private export failure and alias evidence --- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 2 ++ docs/product-technical-gap-baseline.md | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/TRD.md b/docs/TRD.md index 6301e10e..875b633c 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -109,7 +109,7 @@ Provider deserialization captures each complete JSON object before projecting me The complete metadata-review evaluator rejects unequal label cardinality or nonempty `pending_source_item_keys` with `IncompleteReview` before governance. The shared evaluator then recomputes the complete inventory and pending ancestry, so clearing pending keys and rewriting the proposal digest still fails local validation. Because shared validation rejects blank, duplicate, and unknown keys, equal cardinality proves bibliographic label coverage. Sampled evaluation still supports pending sources; completion does not prove a Zotero mutation or full-text approval. 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. -`conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` reads one live snapshot for the two owner-only outputs. Failure cleanup requires separate review for pathname races; pair creation is not an atomic publication. +`conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` validates both canonical destinations and rejects aliases before reading one live snapshot. Both artifacts are built and serialized before the first file write. Writes are sequential, not an atomic publication. Any write failure propagates without pathname deletion or implicit buffer-flush retry. A complete report and an empty/partial worksheet may remain; that is not a completed pair. Inspect retained owner-only files and use new output paths for another capture. Never automatically overwrite or infer approval from a surviving artifact. A successful flush is not a crash-durability guarantee. 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 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 7d92be5f..ecb7082e 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -5,6 +5,8 @@ ## Context +Proposed September 7 export-failure amendment: when exporting sensitive report/worksheet pairs, a failed write may race with pathname replacement, and destroying a buffered writer may write pending bytes after failure. We choose to preserve artifacts, disassemble the existing buffer without flushing, and propagate the original error, rejecting pathname cleanup or an automatic retry. This extends the existing private-creation policy to the common output writer and the pair's second-file failure. The cost is retained empty/partial files and operator inspection; sequential export is not a transaction or crash-durable publication. Both canonical destinations must differ before the Local API read, including aliases such as `/tmp` and `/private/tmp`. RED `68575a6` proves replacement deletion and implicit drop-flush; `ab391b2` removes both behaviors. RED `fb5b5e5` proves alias collision; `e928858` rejects it before snapshot capture. Later CLI owners must inherit this implementation instead of restoring cleanup. No Zotero mutation or approval authority follows from local output. + CWL needs a reproducible inventory of ontology research without turning keyword matches into authoritative library organization. The current desktop is Zotero 9.0.6, whose Local API supports reads but not writes. Zotero documents Local API writes only for Zotero 10+, where they require user-granted authorization and `Zotero-Server-ID`; this slice therefore has no mutation capability. The library is mutable while pagination is in progress, duplicate metadata does not prove that two records should be merged, and the local report contains titles and item keys that must not be written into the repository. Zotero's Local API documentation states that production clients should request `Zotero-API-Version: 3`; the response exposes `Zotero-API-Version` and `Zotero-Schema-Version`. The API version is the compatibility contract. The schema version is therefore recorded and required to remain stable across the snapshot, but it is not hard-coded to the developer workstation's current schema 42 because Zotero can legitimately revise the local data schema while retaining API v3 compatibility. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 18a8a441..31aba87c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -6,6 +6,16 @@ This file records code-current product and technical gaps. Exact PR/check/run co ## September 6 source inventory checkpoint +### September 7 PR26 private export repair + +Final source `58ff5985890d5a0b4aaadaa1f8d604e1bc96a1e2` passes 163 tests/24 suites including three doctests, strict Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged pinned coverage gate passes 294/294 functions, 2,536/2,536 normalized regions and 422/422 normalized branches. Raw coverage remains 3,382/3,444 lines, 5,139/5,246 regions and 377/422 branches, not 100%. Logs: `/tmp/conceptweave-pr26-verified.log` and `/tmp/conceptweave-pr26-{clippy,rustdoc,coverage}-verified.log`. The earlier coverage failure is retained in `/tmp/conceptweave-pr26-coverage.log`; it is not a successful checkpoint. + +Original PR26 `e2dc6006ed3e56d8388e82912826cf37efed0541` passed 128 tests/24 suites. Ordinary merge `227b3e9` retains it and PR25 `51631fbf711b403a40f3b9fafa2ec3958d54ceaf`; integrated tests passed 160/24. RED `68575a6` compiled and failed both regression tests: a replacement file was deleted after write failure, and buffered bytes were flushed while dropping a failed writer. `ab391b2` preserves the explicit write result, disassembles the buffer without retry, and removes pathname cleanup from both the shared writer and second-artifact failure. Existing private creation, successful write and overwrite-refusal checks remain. + +RED `fb5b5e5` compiled and failed canonical-alias admission. `e928858` rejects equal canonical destinations before the Local API read; both artifacts are still serialized from one captured report before either is written. Independent read-only review found no further production defect. The first coverage run exposed two untested error propagation points in the extracted admission function; `58ff598` adds both invalid-path cases without changing runtime or coverage exclusions. Final verification is recorded separately below. + +TRD and Proposed ADR 0006 describe retained partial files and sequential, nontransactional output. A report surviving a failed worksheet write is not a completed pair, approval or durable publication. Root and later CLI owners still require this inherited fix; PR27 finalization must also compare the newly required worksheet proposal binding before governance. Actual decisions and independent approvals remain 0/3,715 plus four unresolved standalone sources. No real source metadata or authority was created for these synthetic tests. Native Visual Inspection was attempted but the Mac is locked, so no new screen evidence exists. No hosted GREEN, protected approval/merge or release is claimed. + ### PR25 worksheet admission and identity repair Original PR25 `c6b4c17e931951a2e1d4ea79ac79363f6306a5bf` passed 126 tests/24 suites. Normal merge `4a1a3bb` retains it and PR24 `35c57ca4510a65cf48069285d78b95cf47db65ba`; integrated tests passed 153/24. RED `900038e` compiled with 4 passing and 2 failing tests: omitted retained inventory and hidden pending keys still produced worksheets. `5b54d06` reuses shared report validation and removes 54 lines of divergent audit/coordinate checks. Valid standalone, orphan, cyclic and attached source metadata remains reviewable with blank decisions. Completion admission remains distinct.