From fba7bda9508b745459735b05d400a099a84ba30f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:27:26 +0900 Subject: [PATCH 01/32] test(zotero): require aggregate steward progress --- .../tests/steward_review_progress.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/steward_review_progress.rs diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs new file mode 100644 index 00000000..bb37dfbc --- /dev/null +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -0,0 +1,91 @@ +use conceptweave_zotero::{ + Disposition, ItemData, WorksheetError, ZoteroItem, assess_steward_review_progress, + build_steward_review_worksheet, classify_snapshot, +}; + +fn report() -> conceptweave_zotero::ClassificationReport { + classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + ZoteroItem { + key: "B".into(), + version: 8, + data: ItemData { + item_type: "book".into(), + title: "unknown vocabulary".into(), + abstract_note: "private review context".into(), + doi: String::new(), + parent_item: String::new(), + collections: vec!["PRIVATE_COLLECTION".into()], + tags: vec![], + }, + }, + ZoteroItem { + key: "A".into(), + version: 7, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: "10.1000/private".into(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }, + ], + ) +} + +#[test] +fn progress_is_exact_aggregate_only_and_fail_closed() { + let report = report(); + let mut worksheet = build_steward_review_worksheet(&report).unwrap(); + + let blank = assess_steward_review_progress(&report, &worksheet).unwrap(); + assert_eq!(blank.total_count, 2); + assert_eq!(blank.decided_count, 0); + assert_eq!(blank.remaining_count, 2); + assert!(!blank.complete); + + worksheet.decisions[0].reviewed_disposition = Some(Disposition::Generation); + let partial = assess_steward_review_progress(&report, &worksheet).unwrap(); + assert_eq!(partial.decided_count, 1); + assert_eq!(partial.remaining_count, 1); + assert!(!partial.complete); + let serialized = serde_json::to_string(&partial).unwrap(); + assert!(!serialized.contains("PRIVATE")); + assert!(!serialized.contains("10.1000")); + assert!(!serialized.contains("item_key")); + assert!(!serialized.contains("reviewer")); + assert!(!serialized.contains("receipt")); + + worksheet.decisions[1].reviewed_disposition = Some(Disposition::OutOfScope); + let complete = assess_steward_review_progress(&report, &worksheet).unwrap(); + assert_eq!(complete.decided_count, 2); + assert_eq!(complete.remaining_count, 0); + assert!(complete.complete); + + let mut invalid = worksheet.clone(); + invalid.decisions[0].reviewed_disposition = Some(Disposition::NeedsStewardReview); + assert_eq!( + assess_steward_review_progress(&report, &invalid), + Err(WorksheetError::InvalidReport) + ); + + let mut tampered = worksheet.clone(); + tampered.decisions.swap(0, 1); + assert_eq!( + assess_steward_review_progress(&report, &tampered), + Err(WorksheetError::InvalidReport) + ); + + let mut missing = worksheet; + missing.decisions.pop(); + assert_eq!( + assess_steward_review_progress(&report, &missing), + Err(WorksheetError::InvalidReport) + ); +} From bace2ac4367a00d7e67dd54b88b0de0f71088be0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:29:03 +0900 Subject: [PATCH 02/32] feat(zotero): report steward review progress --- crates/conceptweave-zotero/src/lib.rs | 83 +++++++++++++++++++++++--- crates/conceptweave-zotero/src/main.rs | 70 +++++++++++++++++++++- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 557f28c0..f7683072 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1060,6 +1060,25 @@ pub struct StewardReviewWorksheet { pub decisions: Vec, } +/// Aggregate-only checkpoint for a human steward review campaign. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StewardReviewProgress { + /// Zotero library revision bound to the original report. + pub library_version: u64, + /// Classifier revision whose proposals are being reviewed. + pub rule_revision: String, + /// Opaque immutable snapshot identity. + pub snapshot_digest: String, + /// Number of bibliographic decisions required for completion. + pub total_count: usize, + /// Number of non-abstention decisions supplied by a steward. + pub decided_count: usize, + /// Number of decisions still blank. + pub remaining_count: usize, + /// Whether every decision slot is filled; this does not confer approval. + pub complete: bool, +} + /// A classification report cannot safely produce a review worksheet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorksheetError { @@ -1163,6 +1182,57 @@ pub fn build_steward_review_worksheet( }) } +/// Validates an in-progress worksheet and returns privacy-safe aggregate progress. +pub fn assess_steward_review_progress( + report: &ClassificationReport, + worksheet: &StewardReviewWorksheet, +) -> Result { + let expected = build_steward_review_worksheet(report)?; + validate_steward_review_worksheet_against(&expected, worksheet)?; + let decided_count = worksheet + .decisions + .iter() + .filter(|decision| decision.reviewed_disposition.is_some()) + .count(); + let total_count = worksheet.decisions.len(); + let remaining_count = total_count - decided_count; + Ok(StewardReviewProgress { + library_version: worksheet.library_version, + rule_revision: worksheet.rule_revision.clone(), + snapshot_digest: worksheet.snapshot_digest.clone(), + total_count, + decided_count, + remaining_count, + complete: remaining_count == 0, + }) +} + +fn validate_steward_review_worksheet_against( + expected: &StewardReviewWorksheet, + worksheet: &StewardReviewWorksheet, +) -> Result<(), WorksheetError> { + if worksheet.library_version != expected.library_version + || worksheet.rule_revision != expected.rule_revision + || worksheet.snapshot_digest != expected.snapshot_digest + || worksheet.snapshot_items != expected.snapshot_items + || worksheet.decisions.len() != expected.decisions.len() + || worksheet + .decisions + .iter() + .zip(&expected.decisions) + .any(|(decision, expected)| { + decision.item_key != expected.item_key + || decision.item_version != expected.item_version + || decision.proposed_disposition != expected.proposed_disposition + || decision.abstention_reason != expected.abstention_reason + || decision.reviewed_disposition == Some(Disposition::NeedsStewardReview) + }) + { + return Err(WorksheetError::InvalidReport); + } + Ok(()) +} + /// One steward-reviewed expected disposition in a local golden set. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct GoldenLabel { @@ -1249,16 +1319,11 @@ pub fn reviewed_golden_set_from_worksheet( { return Err(EvaluationError::SnapshotMismatch); } - + if validate_steward_review_worksheet_against(&expected, worksheet).is_err() { + return Err(EvaluationError::InvalidReview); + } let mut labels = Vec::with_capacity(worksheet.decisions.len()); - for (decision, expected_decision) in worksheet.decisions.iter().zip(expected.decisions) { - if decision.item_key != expected_decision.item_key - || decision.item_version != expected_decision.item_version - || decision.proposed_disposition != expected_decision.proposed_disposition - || decision.abstention_reason != expected_decision.abstention_reason - { - return Err(EvaluationError::InvalidReview); - } + for decision in &worksheet.decisions { let expected_disposition = decision .reviewed_disposition .ok_or(EvaluationError::IncompleteReview)?; diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index eccee646..60bc363c 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -3,7 +3,8 @@ use conceptweave_zotero::{ ClassificationReport, GoldenSetApproval, StewardReviewWorksheet, - build_steward_review_worksheet, read_local_snapshot, reviewed_golden_set_from_worksheet, + assess_steward_review_progress, build_steward_review_worksheet, read_local_snapshot, + reviewed_golden_set_from_worksheet, }; use serde::de::DeserializeOwned; use std::collections::BTreeSet; @@ -12,7 +13,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; -const USAGE: &str = "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json | --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json"; +const USAGE: &str = "usage: conceptweave-zotero /tmp/REPORT.json | --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json | --review-progress /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/PROGRESS.json | --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json"; const MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024; #[derive(Debug, PartialEq, Eq)] @@ -22,6 +23,11 @@ enum OutputRequest { report: String, worksheet: String, }, + ReviewProgress { + report: String, + worksheet: String, + output: String, + }, Finalize { report: String, worksheet: String, @@ -48,6 +54,24 @@ where return Err("report and worksheet output paths must differ"); } OutputRequest::Worksheet { report, worksheet } + } else if first == "--review-progress" { + let report = args + .next() + .ok_or("--review-progress requires three artifact paths")?; + let worksheet = args + .next() + .ok_or("--review-progress requires three artifact paths")?; + let output = args + .next() + .ok_or("--review-progress requires three artifact paths")?; + if BTreeSet::from([report.as_str(), worksheet.as_str(), output.as_str()]).len() != 3 { + return Err("review progress artifact paths must differ"); + } + OutputRequest::ReviewProgress { + report, + worksheet, + output, + } } else if first == "--finalize" { let report = args .next() @@ -289,6 +313,17 @@ fn main() -> Result<(), Box> { return Err(error.into()); } } + OutputRequest::ReviewProgress { + report, + worksheet, + output, + } => { + let output = validate_output_path(&output)?; + let report: ClassificationReport = read_private_json(&report)?; + let worksheet: StewardReviewWorksheet = read_private_json(&worksheet)?; + let progress = assess_steward_review_progress(&report, &worksheet)?; + write_private_output(&output, &serde_json::to_vec_pretty(&progress)?)?; + } OutputRequest::Finalize { report, worksheet, @@ -367,6 +402,37 @@ mod tests { ); } + #[test] + fn review_progress_mode_requires_three_distinct_artifact_paths() { + let report = "/tmp/report.json"; + let worksheet = "/tmp/worksheet.json"; + let output = "/tmp/progress.json"; + assert_eq!( + parse_output_request(vec!["--review-progress", report, worksheet, output]), + Ok(OutputRequest::ReviewProgress { + report: report.to_owned(), + worksheet: worksheet.to_owned(), + output: output.to_owned(), + }) + ); + assert!(parse_output_request(vec!["--review-progress"]).is_err()); + assert!(parse_output_request(vec!["--review-progress", report]).is_err()); + assert!(parse_output_request(vec!["--review-progress", report, worksheet]).is_err()); + assert!( + parse_output_request(vec!["--review-progress", report, worksheet, report]).is_err() + ); + assert!( + parse_output_request(vec![ + "--review-progress", + report, + worksheet, + output, + "extra" + ]) + .is_err() + ); + } + #[cfg(unix)] #[test] fn private_json_input_is_owner_only_regular_bounded_and_valid() { From db980fcf557e1924b31ca28788a628b2618197ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:29:21 +0900 Subject: [PATCH 03/32] fix(zotero): preserve finalization error contract --- crates/conceptweave-zotero/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index f7683072..7ad14d0a 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1189,6 +1189,13 @@ pub fn assess_steward_review_progress( ) -> Result { let expected = build_steward_review_worksheet(report)?; validate_steward_review_worksheet_against(&expected, worksheet)?; + if worksheet + .decisions + .iter() + .any(|decision| decision.reviewed_disposition == Some(Disposition::NeedsStewardReview)) + { + return Err(WorksheetError::InvalidReport); + } let decided_count = worksheet .decisions .iter() @@ -1225,7 +1232,6 @@ fn validate_steward_review_worksheet_against( || decision.item_version != expected.item_version || decision.proposed_disposition != expected.proposed_disposition || decision.abstention_reason != expected.abstention_reason - || decision.reviewed_disposition == Some(Disposition::NeedsStewardReview) }) { return Err(WorksheetError::InvalidReport); From 18af5ad06d765692707bb15277b45ce5e1558f7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:29:43 +0900 Subject: [PATCH 04/32] fix(zotero): keep empty campaigns incomplete --- crates/conceptweave-zotero/src/lib.rs | 2 +- crates/conceptweave-zotero/tests/steward_review_progress.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 7ad14d0a..e72bbb8e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1210,7 +1210,7 @@ pub fn assess_steward_review_progress( total_count, decided_count, remaining_count, - complete: remaining_count == 0, + complete: total_count > 0 && remaining_count == 0, }) } diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs index bb37dfbc..a292054f 100644 --- a/crates/conceptweave-zotero/tests/steward_review_progress.rs +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -88,4 +88,10 @@ fn progress_is_exact_aggregate_only_and_fail_closed() { assess_steward_review_progress(&report, &missing), Err(WorksheetError::InvalidReport) ); + + let empty_report = classify_snapshot("9.0.6".into(), None, 42, vec![]); + let empty_worksheet = build_steward_review_worksheet(&empty_report).unwrap(); + let empty = assess_steward_review_progress(&empty_report, &empty_worksheet).unwrap(); + assert_eq!(empty.total_count, 0); + assert!(!empty.complete); } From 973cf7cc757f80579cf56bcd73e8d482b9634af4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:30:12 +0900 Subject: [PATCH 05/32] docs(zotero): define steward progress evidence --- docs/PRD.md | 1 + docs/TRD.md | 1 + docs/UML.md | 2 ++ docs/adr/0006-zotero-research-intake.md | 2 ++ docs/product-technical-gap-baseline.md | 2 ++ 5 files changed, 8 insertions(+) diff --git a/docs/PRD.md b/docs/PRD.md index d341c2cf..445de6a9 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -70,6 +70,7 @@ Evaluate classifier quality only against a steward-reviewed local golden set who A local steward worksheet must bind the library version, rule revision, complete raw-snapshot digest, every observed parent/child item revision, and one editable decision slot per classified bibliographic item. It repeats item identity, proposal, and abstention reason only; titles, abstracts, tags, collections, and matched evidence remain in the separate sensitive report. Invalid or duplicate report identity cannot produce a worksheet. After every decision is filled, worksheet finalization must verify the governance receipt coordinates, unique item identities and revisions, proposal/abstention consistency, and non-abstention truth labels before producing a reviewed golden set. Missing decisions remain incomplete and cannot reach external approval verification. Operators must be able to finalize the saved report, completed worksheet, and approval receipt offline without rereading mutable Zotero state. Every input and the new golden-set output must use distinct owner-only local artifact paths; invalid, oversized, linked, or shared inputs fail closed. +During the human review campaign, operators must be able to validate a partially completed worksheet against its original report and persist aggregate progress without an approval receipt. Progress reports only total, decided, and remaining counts plus a syntactic-completion flag; it never suggests labels or claims correctness, approval, or publication authority. An empty campaign is not complete. Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. diff --git a/docs/TRD.md b/docs/TRD.md index 8662d1a1..835068d7 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -72,6 +72,7 @@ The review worksheet is a deterministic item-key-ordered projection of the repor 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. `conceptweave-zotero --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json` performs that offline transition. All four paths must be distinct. Inputs must be regular, single-link files with exact `0600` permissions, no larger than 16 MiB, and direct children of a canonical system temporary directory; the golden set uses the existing create-new `0600` output boundary. Finalization reads no Zotero state and applies the existing report-bound worksheet and approval validation before writing. +`conceptweave-zotero --review-progress /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/PROGRESS.json` reuses the same private input/output boundary and canonical worksheet comparison for an incremental checkpoint. It accepts blank decisions, counts only explicit non-abstention steward decisions, rejects missing, extra, reordered, shifted, or tampered decisions, and emits only the library/rule/digest coordinates, total, decided, remaining, and `complete`. `complete` requires a nonempty fully decided worksheet and is coverage evidence only; no authority verifier or Zotero call runs. 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/UML.md b/docs/UML.md index d1f177b4..0270adef 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -56,6 +56,8 @@ sequenceDiagram Intake->>Report: write proposals and evidence Intake->>Report: derive snapshot-bound decision worksheet without bibliographic text Report->>Steward: review dispositions and merge candidates + Steward->>Intake: save partially completed worksheet + Intake->>Report: validate original binding; emit aggregate progress only Steward->>Intake: completed worksheet + approval receipt Intake->>Report: offline finalization against the original saved report Report-->>Steward: reviewed golden set or fail-closed validation error diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 9ed50109..fc976190 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -30,6 +30,8 @@ The report is a losslessly deserializable owner-only artifact. Evidence field na The CLI finalizes an original report, completed worksheet, and approval receipt into the reviewed golden set without another Zotero read. Each artifact path must be distinct. Inputs remain direct temporary-directory children, regular single-link files, exact owner-only `0600`, and bounded to 16 MiB; output retains create-new `0600` semantics. This keeps sensitive review material local and makes snapshot drift a validation failure instead of silently substituting current library state. +The same offline boundary may emit an aggregate progress checkpoint for a partial worksheet. The checkpoint revalidates every immutable coordinate and proposal field, counts only human-supplied non-abstention decisions, contains no item or reviewer identity, and treats zero required decisions as incomplete. It is operational coverage evidence, not an approval receipt or semantic-quality result. + 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 68f52dc5..56c198dd 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -56,6 +56,8 @@ Filled worksheets now have a fail-closed conversion into the existing reviewed g The paired owner-only report is losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. The CLI now finalizes the original report, completed worksheet, and approval receipt offline into a create-new owner-only golden set without rereading mutable Zotero state. It requires four distinct paths and rejects non-temporary, linked, non-regular, non-`0600`, or over-16 MiB inputs before the report-bound validation runs. The next measurable Gap is the real steward-labeling campaign itself: 3,715 valid decisions and an externally verified receipt are still absent, so the completion KPI remains 0/3,715. +The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This makes the current 0/3,715 KPI executable during real review without converting syntactic coverage into correctness or approval evidence. The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. + 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 bd55248b5f400de06faef411f575aa7429c7b57b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:31:37 +0900 Subject: [PATCH 06/32] test(zotero): cover progress snapshot rejection --- .../tests/steward_review_progress.rs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs index a292054f..a3ba06aa 100644 --- a/crates/conceptweave-zotero/tests/steward_review_progress.rs +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -3,7 +3,7 @@ use conceptweave_zotero::{ build_steward_review_worksheet, classify_snapshot, }; -fn report() -> conceptweave_zotero::ClassificationReport { +fn classification_report() -> conceptweave_zotero::ClassificationReport { classify_snapshot( "9.0.6".into(), None, @@ -41,7 +41,7 @@ fn report() -> conceptweave_zotero::ClassificationReport { #[test] fn progress_is_exact_aggregate_only_and_fail_closed() { - let report = report(); + let report = classification_report(); let mut worksheet = build_steward_review_worksheet(&report).unwrap(); let blank = assess_steward_review_progress(&report, &worksheet).unwrap(); @@ -89,6 +89,38 @@ fn progress_is_exact_aggregate_only_and_fail_closed() { Err(WorksheetError::InvalidReport) ); + let canonical = build_steward_review_worksheet(&report).unwrap(); + let mut invalid_report = classification_report(); + invalid_report.rule_revision.clear(); + assert_eq!( + assess_steward_review_progress(&invalid_report, &canonical), + Err(WorksheetError::InvalidReport) + ); + let mut shifted = canonical.clone(); + shifted.library_version += 1; + assert_eq!( + assess_steward_review_progress(&report, &shifted), + Err(WorksheetError::InvalidReport) + ); + let mut shifted = canonical.clone(); + shifted.rule_revision.push_str("-changed"); + assert_eq!( + assess_steward_review_progress(&report, &shifted), + Err(WorksheetError::InvalidReport) + ); + let mut shifted = canonical.clone(); + shifted.snapshot_digest.push_str("-changed"); + assert_eq!( + assess_steward_review_progress(&report, &shifted), + Err(WorksheetError::InvalidReport) + ); + let mut shifted = canonical; + shifted.snapshot_items.pop(); + assert_eq!( + assess_steward_review_progress(&report, &shifted), + Err(WorksheetError::InvalidReport) + ); + let empty_report = classify_snapshot("9.0.6".into(), None, 42, vec![]); let empty_worksheet = build_steward_review_worksheet(&empty_report).unwrap(); let empty = assess_steward_review_progress(&empty_report, &empty_worksheet).unwrap(); From 453e3655aa2db211b6eb43b942026e20f5e454ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:41:17 +0900 Subject: [PATCH 07/32] Test review progress artifact identity --- .../tests/finalization_artifact_identity.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs b/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs index 7ba485ad..9c7d30c4 100644 --- a/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs +++ b/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs @@ -9,7 +9,7 @@ use std::os::unix::fs::PermissionsExt; use std::process::Command; #[test] -fn finalization_rejects_distinct_path_spellings_for_one_input_file() { +fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() { let item = ZoteroItem { key: "ITEM".into(), version: 7, @@ -85,6 +85,15 @@ fn finalization_rejects_distinct_path_spellings_for_one_input_file() { ]) .status() .unwrap(); + let progress_status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) + .args([ + "--review-progress", + &report_path, + &worksheet_path, + output.to_str().unwrap(), + ]) + .status() + .unwrap(); let _ = fs::remove_file(&input); let _ = fs::remove_file(&output); @@ -92,4 +101,8 @@ fn finalization_rejects_distinct_path_spellings_for_one_input_file() { !status.success(), "finalization must reject three path spellings that resolve to one input artifact" ); + assert!( + !progress_status.success(), + "progress must reject two path spellings that resolve to one input artifact" + ); } From a5ebe713113b538c874029a0d922dd2a92982783 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:47:25 +0900 Subject: [PATCH 08/32] docs(zotero): include review progress parser mode --- 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 bb7d5ea9..57bc9f9d 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -36,7 +36,7 @@ enum OutputRequest { }, } -/// Parses one mutually exclusive report, worksheet, or finalization request. +/// Parses one mutually exclusive report, worksheet, review-progress, or finalization request. fn parse_output_request(args: I) -> Result where I: IntoIterator, From 4e470c5b252c51517ba4a710de76eb9debb2dccf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:51:33 +0900 Subject: [PATCH 09/32] docs(zotero): record live review campaign checkpoint --- docs/product-technical-gap-baseline.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5d02d846..41b489b2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -58,6 +58,8 @@ The paired owner-only report is losslessly deserializable: its rule revision and The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This makes the current 0/3,715 KPI executable during real review without converting syntactic coverage into correctness or approval evidence. The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. +On 2026-09-05, the exact PR #30 implementation produced a fresh owner-only report/worksheet pair from Zotero 9.0.6 library version 12341 and schema version 42. The immutable snapshot digest was `sha256:c49b08066c4526e520a5f85416543ea20a620a06170e1e15f563088f6bc9e162`; the report observed 8,326 records and produced 3,715/3,715 proposals with 3,715/3,715 complete provenance coordinates, 3,658 abstentions, 56 adjacent-evidence proposals, one semantic-consumption-bridge proposal, 49 duplicate candidates, and zero read failures. The real progress command validated the pair and returned 0 decided, 3,715 remaining, and `complete=false`. The private report, worksheet, and progress artifact SHA-256 values were respectively `aa6c213bcefd2e34114586038a28b88f07cd08295b9149dac26b513daedb2589`, `d70a79f67d1407a47b52b5091d173f1de3098e38409ee97790a45f12b19b1261`, and `ac79c719037aca2d67bb4b0ea7e84babd8a701506a7d2ec274139d260328f524`. This proves a replayable campaign start, not steward correctness or approval; Zotero remained read-only. + 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 2b80c2edc100845c0aec03e0c8b649be79ac32bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:57:52 +0900 Subject: [PATCH 10/32] docs(zotero): separate review coverage from approval KPI --- docs/product-technical-gap-baseline.md | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 41b489b2..e36d9271 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,8 +1,8 @@ # Product / Technical Gap Baseline -**Snapshot:** 2026-09-04 +**Snapshot:** 2026-09-05 -This file records code-current product and technical gaps. Exact PR/check/run coordinates below are evidence snapshots, not mutable-head dependencies. Live protected-branch, PR, issue and workflow state wins whenever it advances after this snapshot. Because this documentation update itself creates a successor Foundation head, the Foundation SHA below is explicitly the exact pre-refresh head. +This file records code-current product and technical gaps. Exact PR/check/run coordinates below are evidence snapshots, not mutable-head dependencies. Live protected-branch, PR, issue and workflow state wins whenever it advances after this snapshot. Because this documentation update itself creates a successor child head, exact SHAs below are the evidence coordinates observed immediately before this refresh. ## Protected truth and active stack @@ -10,9 +10,9 @@ Protected/default `main` remains `f4f440dd58c77d7cd90dff8a1eb2eeb9a9940425`; onl The active dependency stack observed immediately before this baseline refresh is: -1. Foundation PR #1 — pre-refresh exact head `9a6aa93ed05dd9cc56825258e072b222d80f85de`, open/non-Draft/mergeable. A public-contract mismatch was reproduced locally before the repair: the Draft 2020-12 `semantic-candidate` schema accepted `publication_state=draft` together with `truth_status=authoritative`, although the Rust domain maps Draft/Validated/Reviewed to Inferred, Proposed to Proposed, Published to Authoritative, Superseded to Superseded and Rejected to Rejected. Test-first `18249652...` added an invalid fixture and Product AJV assertion; minimum fix `9a6aa93...` makes the language-neutral schema enforce the same state/truth mapping as Rust. Local exhaustive verification covered all 7 publication states × 6 truth statuses with zero mapping mismatches. This is local exact-contract GREEN, not hosted exact-head GREEN. Product `33871067722` / job `101016914449`, SAST `33871067759`, and Security `33871067702` remain non-terminal; Product has no executed steps yet. -2. Client Consumption PR #5 — pre-refresh exact head `4a771af962306febb4318aed4de48254d96f32f9`, Draft/open. It non-force adopted the Foundation truth-state contract. The first restack accidentally replaced the child-specific Product JSON-contract checks with the narrower Foundation workflow; that repair finding was fixed immediately by `4a771af...`. Old-child `67c104...` → current comparison now changes only the new candidate mismatch fixture, the candidate schema and five added Product-workflow lines, while all pre-existing semantic-release and supersession checks remain intact. Product `33871312459` is non-terminal. The next Client RED remains its missing language-neutral supersession/publication schema and fixtures after inherited Foundation quality is terminal. -3. Source Observation PR #6 — pre-refresh exact head `e72345ac9f407e5732b3e3cc5a2d78b55b10cad2`, Draft/open. It non-force adopted the same Foundation contract; old-child `a0197ba...` → current comparison changes only the candidate mismatch fixture, candidate schema and five Product-workflow lines. Source Observation semantic delta is preserved. Product `33871231617` is queued. The registry-identity test still requires immutable snapshot/source-receipt provenance to obey the Source Observation port's opaque ≤128-byte lowercase multiword `snake_case` key boundary; production `PostgresSchemaSnapshot::new` still validates this field only as nonblank, so that semantic lane remains open. +1. Foundation PR #1 — exact head `8e8783286eac7567803568d9a91010daaf028074`, open/non-Draft/mergeable. The language-neutral truth/publication-state repair and doctoring records are present. Runner admission is partial: scope-detection entry work has executed, while remaining SAST/Security/CodeQL work is not all terminal. Predecessor/local evidence does not transfer, so hosted exact-head GREEN is not claimed. +2. Client Consumption PR #5 — exact head `475000ed50aaedf77ad6cc5c1e5664fb7d4c5dc8`, Draft/open/mergeable. Current source repairs the returned semantic-release/supersession and coverage findings. No pull-request workflow run is materialized for this exact head, so protected exact-head GREEN is not claimed. +3. Source Observation PR #6 — exact head `2194a4ed1b8262d76dca0e7708cfd30114372a2b`, Draft/open/mergeable. The current test-only head requires exact PostgreSQL column-targeted `ON DELETE SET NULL (...)` / `SET DEFAULT (...)` coordinates; production remains unchanged pending executed RED. Registry/ACL-authorized source identity provenance remains a separate semantic gap and must not be replaced by credential-word heuristics. Predecessor reviews/checks never transfer to successor heads. No force-push, destructive rebase, self-approval, fail-open scanner substitution or routine administrator bypass is acceptance evidence. @@ -21,46 +21,46 @@ Predecessor reviews/checks never transfer to successor heads. No force-push, des | Area | Status | Evidence / next verification | | --- | --- | --- | | Product boundary | ACTIVE_PR | PRD/TRD/ADR/context map define ConceptWeave ownership of `observe -> discover -> propose -> align -> validate -> review -> publish`, governed immutable semantic releases and stable Client contracts. Foreign product truth remains behind released/versioned ports and ACLs. | -| Truth/publication lifecycle | REPAIRED_PENDING_CI | Rust already derives truth status from publication state. The public JSON Schema now enforces the same mapping for every state, preventing pre-publication `authoritative` claims by non-Rust consumers. New invalid fixture proves the previously admitted Draft+Authoritative combination. Hosted exact-head Product evidence is still non-terminal. | -| Source Observation | ACTIVE_CHILD | Immutable PostgreSQL table/column/PK/unique/FK/CHECK evidence, exact identifiers, canonical snapshot digest syntax, UTC provenance, receipts, bounded request budgets/cancellation and opaque source registry keys exist. Registry-key consistency at the immutable snapshot boundary is the current semantic TDD lane. No live PostgreSQL adapter is claimed; ADR 0004 remains Proposed. | -| Client Consumption | ACTIVE_CHILD | Offline Published+Authoritative admission, compatibility, exact resolution/diff, canonical digest verification, detached artifact verification and explicit supersession validation exist. Public supersession/publication schema/fixtures remain the next Client contract lane after Foundation terminal quality. | +| Truth/publication lifecycle | REPAIRED_PENDING_CI | Rust derives truth status from publication state and the public JSON Schema enforces the same mapping, preventing pre-publication `authoritative` claims by non-Rust consumers. Hosted exact-head evidence remains non-terminal. | +| Source Observation | ACTIVE_CHILD | Immutable PostgreSQL table/column/PK/unique/FK/CHECK evidence, exact identifiers, canonical snapshot digest syntax, UTC provenance, receipts, bounded request budgets/cancellation and opaque source registry keys exist. Targeted FK delete-action provenance and registry/ACL-resolved source identity are the current semantic TDD lanes. No live PostgreSQL adapter is claimed; ADR 0004 remains Proposed. | +| Client Consumption | ACTIVE_CHILD | Offline Published+Authoritative admission, compatibility, exact resolution/diff, canonical digest verification, detached artifact verification and explicit supersession validation exist. Exact-head execution/security/dependency evidence remains pending. | | Quality gate | ACTIVE_PR | Rust 1.98.0, unsafe forbidden, public docs required, exact checkout, fmt, Clippy, tests, rustdoc, owned 100% coverage, Draft-2020-12 schema fixtures, lock freshness and clean-tree checks. Every head movement requires fresh exact-head evidence. | -| Security / dependency review | BLOCKED_OWNER | Prior Security evidence showed authoritative GitHub Dependency Review availability was not satisfied. `.github#810` owns central repair; scanner substitution and 403-as-success are forbidden. | -| Review / runner admission | BLOCKED_OWNER | Central queue pressure is materially lower than its peak but current ConceptWeave canaries remain non-terminal. `.github#712/#1531/#1796` own central admission/review-amplification. Queue depth alone is not consumer GREEN. | +| Security / dependency review | BLOCKED_OWNER | Authoritative dependency/security gates remain required; scanner substitution and 403-as-success are forbidden. Central `.github` owns organization-level workflow repair. | +| Review / runner admission | BLOCKED_OWNER | Central queue pressure is materially lower than its peak but current ConceptWeave exact heads remain non-terminal. Queue depth alone is not consumer GREEN. | | Standards / research | REPAIRED_PENDING_CI | Doctoring binds He et al. to CEUR/ISWC 2023 and Amini et al. to the Springer LNCS 15459 version of record published in 2025 while retaining KGSWC 2024 study/conference lineage in traceability. Hosted exact-head evidence remains non-terminal. | | Release | NOT_STARTED | No immutable ConceptWeave release exists. Version/CHANGELOG/tag/package/semantic_release/SBOM/provenance/reproducibility/rollback are required on the exact protected release head. | ## Central control-plane evidence -Protected central source is `.github/main@c31d2e5471fc5daf9d72ff67cde6a8874b736deb` at this snapshot, after merged #1852 aligned current-main workflow contracts. This is evidence only, not a ConceptWeave dependency. +Protected central source is `.github/main@dcd35b7653854edb2ea26a87bac2035f12d8d903` at this snapshot. Merged #1865 (`fix(codeql): release runners with exact job wake-up`) follows the earlier native-concurrency and stale-head recovery repairs. This is operational evidence only, not a ConceptWeave dependency. -- `.github#1821` queue-ownership repair remains integrated: organization sweep no longer owns repository-wide queued/in-progress Actions inventory or broad cancellation; native per-PR concurrency and repository-local exact-head coalescing own supersession. +- Organization sweep no longer owns repository-wide queued/in-progress inventory or broad cancellation; native per-PR concurrency and repository-local exact-head coalescing own supersession. - Later consolidation removed merge-scheduler required-check fanout, centralized CodeQL PR ownership and consolidated empty-PR/quality lanes. -- Fresh `.github` queued inventory is `245`. This is far below the ~1,900 peak but above some recent lower observations, so it is neither terminal recovery nor consumer acceptance. +- Fresh `.github` queued inventory is `214`. This remains non-terminal and does not substitute for ConceptWeave exact-head acceptance. ## P0 product gaps after the current TDD lanes ### Zotero research classification slice -The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. Thin adapter boundaries connect the transport to generic reviewed-write and rollback executors without duplicating mutation logic. Rollback evidence now binds the server and complete expected post-write metadata; the executor preflights all items at one version, consumes reverse receipt order, reconciles restored, unchanged, and indeterminate failures, and returns secret-free remaining-work evidence. This is synthetic evidence only: no live prompt, write, or rollback ran and no key is committed. +The successor authorization slice adds one-shot Zotero 10 Local API authorization with exact server binding, a bounded private 32-character key, proven same-server denial and rate-limit outcomes, and distinct expired-authorization, matching-server stale-precondition, and database-switch errors across read and write paths. Thin adapter boundaries connect the transport to generic reviewed-write and rollback executors without duplicating mutation logic. Rollback evidence binds the server and complete expected post-write metadata; the executor preflights all items at one version, consumes reverse receipt order, reconciles restored, unchanged, and indeterminate failures, and returns secret-free remaining-work evidence. This is synthetic evidence only: no live prompt, write, or rollback ran and no key is committed. Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The 3,658-item abstention queue now preserves each nonempty abstract exactly once in its sensitive local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. On the supported Unix CLI, report creation restores exact owner-only (`0600`) permissions after umask application and other platforms fail closed. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. +The 3,658-item abstention queue preserves each nonempty abstract exactly once in its sensitive local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. On the supported Unix CLI, report creation restores exact owner-only (`0600`) permissions after umask application and other platforms fail closed. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. -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 golden-set evaluation contract 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 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 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 has a deterministic local worksheet contract rather than an informal report-editing step. The 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. -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. +Filled worksheets 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 externally approved-label completion measure remains 0/3,715 until real steward decisions and an externally verified approval receipt exist. -The paired owner-only report is losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. The CLI now finalizes the original report, completed worksheet, and approval receipt offline into a create-new owner-only golden set without rereading mutable Zotero state. It requires four distinct path arguments, proves the three opened inputs have distinct Unix device/inode identities, and rejects non-temporary, linked, non-regular, non-`0600`, or over-16 MiB inputs before the report-bound validation runs. The next measurable Gap is the real steward-labeling campaign itself: 3,715 valid decisions and an externally verified receipt are still absent, so the completion KPI remains 0/3,715. +The paired owner-only report is losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. The CLI finalizes the original report, completed worksheet, and approval receipt offline into a create-new owner-only golden set without rereading mutable Zotero state. It requires four distinct path arguments, proves the three opened inputs have distinct Unix device/inode identities, and rejects non-temporary, linked, non-regular, non-`0600`, or over-16 MiB inputs before the report-bound validation runs. The next measurable Gap is the real steward-labeling campaign itself: 3,715 valid decisions and an externally verified receipt are still absent. -The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This makes the current 0/3,715 KPI executable during real review without converting syntactic coverage into correctness or approval evidence. The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. +The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This creates a separate unverified worksheet-review coverage measure (`decided / 3,715`) for campaign operations; it does **not** change or satisfy the externally approved-label completion measure (`approved / 3,715`). The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. -On 2026-09-05, the exact PR #30 implementation produced a fresh owner-only report/worksheet pair from Zotero 9.0.6 library version 12341 and schema version 42. The immutable snapshot digest was `sha256:c49b08066c4526e520a5f85416543ea20a620a06170e1e15f563088f6bc9e162`; the report observed 8,326 records and produced 3,715/3,715 proposals with 3,715/3,715 complete provenance coordinates, 3,658 abstentions, 56 adjacent-evidence proposals, one semantic-consumption-bridge proposal, 49 duplicate candidates, and zero read failures. The real progress command validated the pair and returned 0 decided, 3,715 remaining, and `complete=false`. The private report, worksheet, and progress artifact SHA-256 values were respectively `aa6c213bcefd2e34114586038a28b88f07cd08295b9149dac26b513daedb2589`, `d70a79f67d1407a47b52b5091d173f1de3098e38409ee97790a45f12b19b1261`, and `ac79c719037aca2d67bb4b0ea7e84babd8a701506a7d2ec274139d260328f524`. This proves a replayable campaign start, not steward correctness or approval; Zotero remained read-only. +On 2026-09-05, the exact PR #30 implementation produced a fresh owner-only report/worksheet pair from Zotero 9.0.6 library version 12341 and schema version 42. The immutable snapshot digest was `sha256:c49b08066c4526e520a5f85416543ea20a620a06170e1e15f563088f6bc9e162`; the report observed 8,326 records and produced 3,715/3,715 proposals with 3,715/3,715 complete provenance coordinates, 3,658 abstentions, 56 adjacent-evidence proposals, one semantic-consumption-bridge proposal, 49 duplicate candidates, and zero read failures. The real progress command validated the pair and returned unverified worksheet coverage of 0 decided, 3,715 remaining, and `complete=false`. The private report, worksheet, and progress artifact SHA-256 values were respectively `aa6c213bcefd2e34114586038a28b88f07cd08295b9149dac26b513daedb2589`, `d70a79f67d1407a47b52b5091d173f1de3098e38409ee97790a45f12b19b1261`, and `ac79c719037aca2d67bb4b0ea7e84babd8a701506a7d2ec274139d260328f524`. This proves a replayable campaign start, not steward correctness or approval; Zotero remained read-only. Externally approved labels remain independently 0/3,715. -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. +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, unverified worksheet coverage, 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. 2. **Ontology discovery** — deterministic term/concept/taxonomy/non-taxonomic-relation candidate generation with exact source receipts and abstention for unsupported semantics. From f3f530f1a055d86590888de7e9f7736a87bd4a6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:11:52 +0900 Subject: [PATCH 11/32] test(zotero): require no-follow private input open --- .../tests/private_input_open_security.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/private_input_open_security.rs diff --git a/crates/conceptweave-zotero/tests/private_input_open_security.rs b/crates/conceptweave-zotero/tests/private_input_open_security.rs new file mode 100644 index 00000000..94eee54b --- /dev/null +++ b/crates/conceptweave-zotero/tests/private_input_open_security.rs @@ -0,0 +1,24 @@ +#[test] +fn owner_only_input_open_must_not_follow_final_component_symlinks() { + let source = include_str!("../src/main.rs"); + let open_start = source + .find("fn open_with_metadata") + .expect("private artifact open helper must remain present"); + let open_tail = &source[open_start..]; + let open_end = open_tail + .find("\n#[cfg(unix)]") + .unwrap_or(open_tail.len()); + let open_body = &open_tail[..open_end]; + + assert!( + !open_body.contains("File::open(path)"), + "checked-path metadata followed by File::open is vulnerable to a final-component symlink swap" + ); + assert!( + source.contains("O_NOFOLLOW") + || source.contains("no_follow") + || source.contains("nofollow") + || source.contains("follow_links(false)"), + "the opened private-artifact handle must be obtained with an explicit no-follow primitive" + ); +} From 12ea86a749c06d3682eb4cec644d70d36fc08cbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:12:55 +0900 Subject: [PATCH 12/32] docs(security): record no-follow artifact boundary --- THREAT_MODEL.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index db08f607..46e79551 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -30,7 +30,8 @@ Source artifacts, imported ontologies, provider responses, model outputs, web-re 7. tenant/workspace evidence disclosure; 8. SSRF, DNS rebinding, unsafe redirects, or unbounded external retrieval; 9. dependency/provider compromise or unexpected retention; -10. write-back without reviewed before/after/rollback evidence and exact preconditions. +10. write-back without reviewed before/after/rollback evidence and exact preconditions; +11. same-host filesystem races replacing a checked owner-only review artifact path with a symlink before the file descriptor is opened. ## Zotero 10+ Local API transport boundary @@ -64,6 +65,14 @@ Neither path may reinterpret `Zotero-Server-ID` as cryptographic peer authentica - attachments and bibliographic source records are not deleted by classification write-back; - descendant integration evidence never back-proves an unresolved predecessor contract. +## Owner-only review artifact filesystem boundary + +Saved report, worksheet, approval, progress, and golden-set artifacts are sensitive local review material. Path policy alone is not the security boundary. A direct-temp-child path may be checked as a regular non-symlink and still be replaced by a symlink before a later symlink-following open. + +The opened file descriptor must therefore be obtained with a Unix final-component no-follow primitive such as `O_NOFOLLOW` or an equivalent safe abstraction. After open, ConceptWeave still verifies that the opened device/inode matches the checked regular file, link count is one, mode is exactly `0600`, and the bounded-read contract is satisfied. A second pathname check is not an equivalent repair because it leaves another check/open race window. + +The exact PR #30 test-only contract records this invariant. Until that contract executes RED and the minimum no-follow open repair reaches one unchanged exact-head GREEN, offline review/finalization artifacts remain acceptance-gated even though existing direct-symlink and inode-alias tests pass. + ## Release gate A capability is not release-ready while a valid security finding lacks a deterministic test or equivalent machine-verifiable contract, while required exact-head checks are non-terminal, or while the implemented transport cannot satisfy the advertised security claim. Documentation must describe residual risk without upgrading provider guarantees by inference. From 4772ae9748003622a922f087b3fea116f133b0b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:15:03 +0900 Subject: [PATCH 13/32] docs(gap): bind campaign to parent provenance --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9e0f5370..67713f86 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -60,7 +60,7 @@ The paired owner-only report is losslessly deserializable: its rule revision and The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This makes the current 0/3,715 KPI executable during real review without converting syntactic coverage into correctness or approval evidence. The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. -On 2026-09-05, the exact PR #30 implementation produced a fresh owner-only report/worksheet pair from Zotero 9.0.6 library version 12341 and schema version 42. The immutable snapshot digest was `sha256:c49b08066c4526e520a5f85416543ea20a620a06170e1e15f563088f6bc9e162`; the report observed 8,326 records and produced 3,715/3,715 proposals with 3,715/3,715 complete provenance coordinates, 3,658 abstentions, 56 adjacent-evidence proposals, one semantic-consumption-bridge proposal, 49 duplicate candidates, and zero read failures. The real progress command validated the pair and returned 0 decided, 3,715 remaining, and `complete=false`. The private report, worksheet, and progress artifact SHA-256 values were respectively `aa6c213bcefd2e34114586038a28b88f07cd08295b9149dac26b513daedb2589`, `d70a79f67d1407a47b52b5091d173f1de3098e38409ee97790a45f12b19b1261`, and `ac79c719037aca2d67bb4b0ea7e84babd8a701506a7d2ec274139d260328f524`. This proves a replayable campaign start, not steward correctness or approval; Zotero remained read-only. +On 2026-09-05, the exact PR #30 implementation produced a fresh owner-only report/worksheet pair from Zotero 9.0.6 library version 12341 and schema version 42 after parent-coordinate validation was repaired. The immutable snapshot digest was `sha256:c49b08066c4526e520a5f85416543ea20a620a06170e1e15f563088f6bc9e162`; the report observed 8,326 records, retained 4,607 child-to-parent coordinates, and produced 3,715/3,715 proposals with 3,715/3,715 complete provenance coordinates, 3,658 abstentions, 56 adjacent-evidence proposals, one semantic-consumption-bridge proposal, 49 duplicate candidates, and zero read failures. The real progress command validated the pair and returned 0 decided, 3,715 remaining, and `complete=false`. The new private report, worksheet, and progress artifact SHA-256 values were respectively `ff13383b88f89fcef94d2f2d7284838b268fb871bed78c75ce5b53bfab2138a8`, `ad32c8352cb7d84ac3bdcd3a60c975f61e2e19adc3a8294d4c680360071e752b`, and `ac79c719037aca2d67bb4b0ea7e84babd8a701506a7d2ec274139d260328f524`; all three files were created with mode `0600`. The earlier report/worksheet hashes are superseded because those artifacts lacked parent coordinates. This proves a replayable campaign start, not steward correctness or approval; Zotero remained read-only. 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 9733d2880827289efef808f0eb214282f8e01987 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:16:26 +0900 Subject: [PATCH 14/32] test(zotero): reproduce final-component symlink swap --- .../tests/private_input_open_security.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/conceptweave-zotero/tests/private_input_open_security.rs b/crates/conceptweave-zotero/tests/private_input_open_security.rs index 94eee54b..dba137a9 100644 --- a/crates/conceptweave-zotero/tests/private_input_open_security.rs +++ b/crates/conceptweave-zotero/tests/private_input_open_security.rs @@ -1,3 +1,42 @@ +#[cfg(unix)] +#[test] +fn checked_path_can_be_swapped_to_a_symlink_that_preserves_inode_identity() { + use std::fs; + use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + let original = std::env::temp_dir().join(format!( + "conceptweave-zotero-{}-{nonce}-checked.json", + std::process::id() + )); + let moved = std::env::temp_dir().join(format!( + "conceptweave-zotero-{}-{nonce}-moved.json", + std::process::id() + )); + + let _ = fs::remove_file(&original); + let _ = fs::remove_file(&moved); + fs::write(&original, br#"{"review":"private"}"#).unwrap(); + fs::set_permissions(&original, fs::Permissions::from_mode(0o600)).unwrap(); + + let checked = fs::symlink_metadata(&original).unwrap(); + assert!(!checked.file_type().is_symlink()); + fs::rename(&original, &moved).unwrap(); + symlink(&moved, &original).unwrap(); + + let followed = fs::File::open(&original).unwrap().metadata().unwrap(); + assert_eq!((checked.dev(), checked.ino()), (followed.dev(), followed.ino())); + assert_eq!(followed.nlink(), 1); + assert_eq!(followed.permissions().mode() & 0o777, 0o600); + + fs::remove_file(&original).unwrap(); + fs::remove_file(&moved).unwrap(); +} + #[test] fn owner_only_input_open_must_not_follow_final_component_symlinks() { let source = include_str!("../src/main.rs"); From 7ccbbbe6857113d3d9fb5fd102ce9c9839b7975b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:18:11 +0900 Subject: [PATCH 15/32] fix(zotero): refuse symlink artifact opens --- Cargo.lock | 1 + crates/conceptweave-zotero/Cargo.toml | 1 + crates/conceptweave-zotero/src/main.rs | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ad534722..5a7db392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ version = "0.1.0" name = "conceptweave-zotero" version = "0.1.0" dependencies = [ + "libc", "serde", "serde_json", "sha2", diff --git a/crates/conceptweave-zotero/Cargo.toml b/crates/conceptweave-zotero/Cargo.toml index 3f492bbe..11eb58ca 100644 --- a/crates/conceptweave-zotero/Cargo.toml +++ b/crates/conceptweave-zotero/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true description = "Read-only Zotero research classification for ConceptWeave" [dependencies] +libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 57bc9f9d..38f599ee 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -164,7 +164,14 @@ fn read_private_json(raw: &str) -> io::Result<(T, ArtifactI #[cfg_attr(coverage_nightly, coverage(off))] /// Opens a review input once and returns metadata from the opened handle. fn open_with_metadata(path: &Path) -> io::Result<(File, fs::Metadata)> { - let file = File::open(path)?; + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW); + let file = options.open(path)?; let metadata = file.metadata()?; Ok((file, metadata)) } From 80ba600f23fbe9d962ac0ee55202ee7130454cf2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:18:45 +0900 Subject: [PATCH 16/32] docs(security): record no-follow repair evidence --- THREAT_MODEL.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 46e79551..4b3df9ef 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -71,7 +71,7 @@ Saved report, worksheet, approval, progress, and golden-set artifacts are sensit The opened file descriptor must therefore be obtained with a Unix final-component no-follow primitive such as `O_NOFOLLOW` or an equivalent safe abstraction. After open, ConceptWeave still verifies that the opened device/inode matches the checked regular file, link count is one, mode is exactly `0600`, and the bounded-read contract is satisfied. A second pathname check is not an equivalent repair because it leaves another check/open race window. -The exact PR #30 test-only contract records this invariant. Until that contract executes RED and the minimum no-follow open repair reaches one unchanged exact-head GREEN, offline review/finalization artifacts remain acceptance-gated even though existing direct-symlink and inode-alias tests pass. +PR #30 commit `9733d28` reproduced the inode-preserving final-component symlink swap and left the no-follow contract RED. Commit `7ccbbbe` repairs the shared input-open helper with Unix `O_NOFOLLOW`; focused security and artifact-identity tests then pass. Offline review/finalization remains acceptance-gated until this repair has terminal protected checks and independent approval on one unchanged exact head. ## Release gate diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f8444103..83a53110 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -56,7 +56,7 @@ The same parent coordinate is authoritative for duplicate review. Restored dupli Filled worksheets 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 externally approved-label completion measure remains 0/3,715 until real steward decisions and an externally verified approval receipt exist. -The paired owner-only report is losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. The CLI finalizes the original report, completed worksheet, and approval receipt offline into a create-new owner-only golden set without rereading mutable Zotero state. It requires four distinct path arguments, proves the three opened inputs have distinct Unix device/inode identities, and rejects non-temporary, linked, non-regular, non-`0600`, or over-16 MiB inputs before the report-bound validation runs. The next measurable Gap is the real steward-labeling campaign itself: 3,715 valid decisions and an externally verified receipt are still absent. +The paired owner-only report is losslessly deserializable: its rule revision and evidence vocabulary use owned JSON values, and roundtrip verification proves the report reconstructs the same canonical worksheet. The CLI finalizes the original report, completed worksheet, and approval receipt offline into a create-new owner-only golden set without rereading mutable Zotero state. It requires four distinct path arguments, opens inputs with Unix final-component no-follow semantics, proves the three opened inputs have distinct device/inode identities, and rejects non-temporary, linked, non-regular, non-`0600`, or over-16 MiB inputs before the report-bound validation runs. The next measurable Gap is the real steward-labeling campaign itself: 3,715 valid decisions and an externally verified receipt are still absent. The steward campaign now has an offline progress checkpoint. It revalidates a partial worksheet against the original saved report, rejects immutable-field drift, reordering, missing or extra rows, and abstention as reviewed truth, then writes only snapshot coordinates and aggregate total/decided/remaining counts through the existing owner-only artifact boundary. It suggests no labels, invokes no authority verifier, and treats an empty workload as incomplete. This creates a separate unverified worksheet-review coverage measure (`decided / 3,715`) for campaign operations; it does **not** change or satisfy the externally approved-label completion measure (`approved / 3,715`). The remaining Gap is human completion of all 3,715 decisions followed by an externally verified receipt; a utility repository is still unwarranted because the workflow has no independent cross-product consumer. From 5b92e7dfabd079e68e2d8489113b08bee33dbd0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:19:01 +0900 Subject: [PATCH 17/32] style(zotero): format no-follow regression tests --- .../tests/private_input_open_security.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/tests/private_input_open_security.rs b/crates/conceptweave-zotero/tests/private_input_open_security.rs index dba137a9..55dd96e7 100644 --- a/crates/conceptweave-zotero/tests/private_input_open_security.rs +++ b/crates/conceptweave-zotero/tests/private_input_open_security.rs @@ -29,7 +29,10 @@ fn checked_path_can_be_swapped_to_a_symlink_that_preserves_inode_identity() { symlink(&moved, &original).unwrap(); let followed = fs::File::open(&original).unwrap().metadata().unwrap(); - assert_eq!((checked.dev(), checked.ino()), (followed.dev(), followed.ino())); + assert_eq!( + (checked.dev(), checked.ino()), + (followed.dev(), followed.ino()) + ); assert_eq!(followed.nlink(), 1); assert_eq!(followed.permissions().mode() & 0o777, 0o600); @@ -44,9 +47,7 @@ fn owner_only_input_open_must_not_follow_final_component_symlinks() { .find("fn open_with_metadata") .expect("private artifact open helper must remain present"); let open_tail = &source[open_start..]; - let open_end = open_tail - .find("\n#[cfg(unix)]") - .unwrap_or(open_tail.len()); + let open_end = open_tail.find("\n#[cfg(unix)]").unwrap_or(open_tail.len()); let open_body = &open_tail[..open_end]; assert!( From aca4b5c75f7ec4e8bf5efc13103d7b769ef43751 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:23:03 +0900 Subject: [PATCH 18/32] test(zotero): keep no-follow boundary in coverage --- .../tests/private_input_open_security.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/conceptweave-zotero/tests/private_input_open_security.rs b/crates/conceptweave-zotero/tests/private_input_open_security.rs index 55dd96e7..8544347e 100644 --- a/crates/conceptweave-zotero/tests/private_input_open_security.rs +++ b/crates/conceptweave-zotero/tests/private_input_open_security.rs @@ -62,3 +62,17 @@ fn owner_only_input_open_must_not_follow_final_component_symlinks() { "the opened private-artifact handle must be obtained with an explicit no-follow primitive" ); } + +#[test] +fn no_follow_private_input_open_must_remain_inside_security_coverage() { + let source = include_str!("../src/main.rs"); + let open_start = source + .find("fn open_with_metadata") + .expect("private artifact open helper must remain present"); + let attribute_window = &source[open_start.saturating_sub(160)..open_start]; + + assert!( + !attribute_window.contains("coverage(off)"), + "the O_NOFOLLOW private-input security boundary must not be removed from owned coverage" + ); +} From 0a50e026758c2f430bcb21cc272f97fdea1f2143 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:25:03 +0900 Subject: [PATCH 19/32] test(zotero): cover no-follow input boundary --- crates/conceptweave-zotero/src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 38f599ee..435d393b 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -161,7 +161,6 @@ fn read_private_json(raw: &str) -> io::Result<(T, ArtifactI } } -#[cfg_attr(coverage_nightly, coverage(off))] /// Opens a review input once and returns metadata from the opened handle. fn open_with_metadata(path: &Path) -> io::Result<(File, fs::Metadata)> { #[cfg(unix)] From b1d391c0ec2806a6fa16a7178e7877fa8fdd4db8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:25:51 +0900 Subject: [PATCH 20/32] refactor(zotero): retain metadata open errors --- 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 435d393b..06988eff 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -171,8 +171,7 @@ fn open_with_metadata(path: &Path) -> io::Result<(File, fs::Metadata)> { #[cfg(unix)] options.custom_flags(libc::O_NOFOLLOW); let file = options.open(path)?; - let metadata = file.metadata()?; - Ok((file, metadata)) + file.metadata().map(|metadata| (file, metadata)) } #[cfg(unix)] From 092d702545724f1b2ecf95a5ffab7836df24f526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:51:02 +0900 Subject: [PATCH 21/32] test(zotero): require private artifact helpers in owned coverage --- .../tests/private_artifact_coverage_contract.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/private_artifact_coverage_contract.rs diff --git a/crates/conceptweave-zotero/tests/private_artifact_coverage_contract.rs b/crates/conceptweave-zotero/tests/private_artifact_coverage_contract.rs new file mode 100644 index 00000000..e1e6d201 --- /dev/null +++ b/crates/conceptweave-zotero/tests/private_artifact_coverage_contract.rs @@ -0,0 +1,16 @@ +#[test] +fn private_artifact_helpers_are_not_excluded_from_owned_coverage() { + let source = include_str!("../src/main.rs"); + for helper in ["fn write_all_and_flush", "fn allowed_output_parents"] { + let position = source + .find(helper) + .unwrap_or_else(|| panic!("missing production helper: {helper}")); + let prefix = &source[..position]; + let window_start = prefix.len().saturating_sub(240); + let declaration_context = &prefix[window_start..]; + assert!( + !declaration_context.contains("#[cfg_attr(coverage_nightly, coverage(off))]"), + "{helper} must remain inside owned coverage rather than bypass the 100% production gate" + ); + } +} From 2dace3906b5c0e2a2c6194c233243894e6e48c59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:04:00 +0900 Subject: [PATCH 22/32] test(zotero): pin validated parent path before private reads --- .../private_artifact_parent_path_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/private_artifact_parent_path_contract.rs diff --git a/crates/conceptweave-zotero/tests/private_artifact_parent_path_contract.rs b/crates/conceptweave-zotero/tests/private_artifact_parent_path_contract.rs new file mode 100644 index 00000000..bfb78393 --- /dev/null +++ b/crates/conceptweave-zotero/tests/private_artifact_parent_path_contract.rs @@ -0,0 +1,21 @@ +#[test] +fn private_artifact_open_does_not_reuse_the_untrusted_raw_path_after_parent_validation() { + let source = include_str!("../src/main.rs"); + let start = source + .find("fn read_private_json") + .expect("missing private artifact reader"); + let end = source[start..] + .find("fn open_with_metadata") + .map(|offset| start + offset) + .expect("missing private artifact open helper"); + let reader = &source[start..end]; + + assert!( + !reader.contains("fs::symlink_metadata(&path)"), + "path metadata must be checked through a path rebuilt from the validated canonical parent" + ); + assert!( + !reader.contains("open_with_metadata(&path)"), + "the opened path must be rebuilt from the validated canonical parent so a replaced parent symlink cannot redirect the read" + ); +} From b06f54a53f457ee002dffccdb6f18e66fef0c3c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:05:20 +0900 Subject: [PATCH 23/32] fix(zotero): pin validated private artifact parent --- crates/conceptweave-zotero/src/main.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 06988eff..81a1a84e 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -130,20 +130,25 @@ fn read_private_json(raw: &str) -> io::Result<(T, ArtifactI let parent = path .parent() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "review input has no parent"))?; - if !allowed_output_parents().contains(&parent.canonicalize()?) { + let resolved_parent = parent.canonicalize()?; + if !allowed_output_parents().contains(&resolved_parent) { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "review input must be a direct child of the system temp directory", )); } - let path_metadata = fs::symlink_metadata(&path)?; + let file_name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "review input has no file name") + })?; + let validated_path = resolved_parent.join(file_name); + let path_metadata = fs::symlink_metadata(&validated_path)?; if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "review input must be a regular file", )); } - let (file, opened_metadata) = open_with_metadata(&path)?; + let (file, opened_metadata) = open_with_metadata(&validated_path)?; #[cfg(not(unix))] { let _ = (path_metadata, opened_metadata, file); From 044da14ac90b2af508838046e066a542ad853fde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:05:45 +0900 Subject: [PATCH 24/32] fix(zotero): cover private artifact helpers --- crates/conceptweave-zotero/src/main.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 81a1a84e..049f9ebb 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -237,7 +237,6 @@ fn write_private_output(path: &Path, content: &[u8]) -> io::Result<()> { write_private_output_with(path, content, write_all_and_flush) } -#[cfg_attr(coverage_nightly, coverage(off))] /// Writes and flushes the complete serialized artifact. fn write_all_and_flush(writer: &mut BufWriter, content: &[u8]) -> io::Result<()> { writer.write_all(content)?; @@ -260,7 +259,6 @@ fn write_private_output_with( Ok(()) } -#[cfg_attr(coverage_nightly, coverage(off))] /// Returns canonical directories in which a sensitive report may be created. fn allowed_output_parents() -> Vec { let mut parents = vec![ From c36f2ba91fbf44b5f5936996337a81b93fd0bf06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:05:56 +0900 Subject: [PATCH 25/32] test(zotero): cover private write failure --- 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 049f9ebb..e2cdfe93 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -655,6 +655,12 @@ mod tests { assert_eq!(fs::read(&output).unwrap(), b"complete"); assert!(write_private_output(&output, b"replacement").is_err()); fs::remove_file(output).unwrap(); + + let read_only = unique_temp_path("read-only-writer"); + fs::write(&read_only, b"input").unwrap(); + let mut writer = BufWriter::with_capacity(1, File::open(&read_only).unwrap()); + assert!(write_all_and_flush(&mut writer, b"content").is_err()); + fs::remove_file(read_only).unwrap(); } fn unique_temp_path(suffix: &str) -> PathBuf { From 9f83c281e04a3f6d76092f4af5ff083d3c75d68b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:07:06 +0900 Subject: [PATCH 26/32] test(zotero): reject missing private artifact filename --- crates/conceptweave-zotero/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index e2cdfe93..9641ce83 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -527,6 +527,7 @@ mod tests { assert_eq!(parsed["accepted"], true); assert!(read_private_json::("relative.json").is_err()); assert!(read_private_json::("/").is_err()); + assert!(read_private_json::("/tmp/..").is_err()); assert!( read_private_json::( unique_temp_path("missing-input").to_str().unwrap() From 509ce7d4b15d755f05a59d1520bd75008207393c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:06:36 +0900 Subject: [PATCH 27/32] test(research): adopt captured-source progress fixtures Signed-off-by: Seongho Bae --- crates/conceptweave-zotero/tests/steward_review_progress.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs index a3ba06aa..ad1d7136 100644 --- a/crates/conceptweave-zotero/tests/steward_review_progress.rs +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -10,6 +10,7 @@ fn classification_report() -> conceptweave_zotero::ClassificationReport { 42, vec![ ZoteroItem { + source_record: None, key: "B".into(), version: 8, data: ItemData { @@ -23,6 +24,7 @@ fn classification_report() -> conceptweave_zotero::ClassificationReport { }, }, ZoteroItem { + source_record: None, key: "A".into(), version: 7, data: ItemData { From 409943448eaef4e9b3a24523e7b83a805a6a9266 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:44:06 +0900 Subject: [PATCH 28/32] test(zotero): expose progress binding and pending scope gaps --- .../tests/steward_review_progress.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs index ad1d7136..cec1518f 100644 --- a/crates/conceptweave-zotero/tests/steward_review_progress.rs +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -129,3 +129,49 @@ fn progress_is_exact_aggregate_only_and_fail_closed() { assert_eq!(empty.total_count, 0); assert!(!empty.complete); } + +#[test] +fn progress_rejects_stale_or_blank_content_binding() { + let mut report = classification_report(); + let worksheet = build_steward_review_worksheet(&report).unwrap(); + let mut blank = worksheet.clone(); + blank.proposal_digest.clear(); + assert_eq!( + assess_steward_review_progress(&report, &blank), + Err(WorksheetError::InvalidReport) + ); + report.classified_items[0] + .title + .push_str(" changed context"); + assert_eq!( + assess_steward_review_progress(&report, &worksheet), + Err(WorksheetError::InvalidReport) + ); +} + +#[test] +fn progress_preserves_pending_source_scope_and_opaque_identity() { + let source = ZoteroItem { + source_record: None, + key: "PRIVATE_SOURCE".into(), + version: 1, + data: ItemData { + item_type: "attachment".into(), + title: "private source".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }; + let report = classify_snapshot("9.0.6".into(), None, 42, vec![source]); + let worksheet = build_steward_review_worksheet(&report).unwrap(); + let progress = assess_steward_review_progress(&report, &worksheet).unwrap(); + let json = serde_json::to_value(&progress).unwrap(); + assert_eq!(json["pending_source_count"], 1); + assert_eq!(json["proposal_digest"], worksheet.proposal_digest); + assert!(!progress.complete); + assert_eq!(progress.total_count, 0); + assert!(!json.to_string().contains("PRIVATE_SOURCE")); +} From bee32f455cade92d0ced50e44c717b1214e21332 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:44:39 +0900 Subject: [PATCH 29/32] fix(zotero): bind progress to content and unresolved source scope --- crates/conceptweave-zotero/src/lib.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 81c4cdec..15ddf1d6 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1148,13 +1148,18 @@ pub struct StewardReviewProgress { pub rule_revision: String, /// Opaque immutable snapshot identity. pub snapshot_digest: String, + /// Opaque binding to the proposals and retained metadata used for these counts. + pub proposal_digest: String, /// Number of bibliographic decisions required for completion. pub total_count: usize, /// Number of non-abstention decisions supplied by a steward. pub decided_count: usize, /// Number of decisions still blank. pub remaining_count: usize, - /// Whether every decision slot is filled; this does not confer approval. + /// Number of source records whose ancestry still requires resolution. + pub pending_source_count: usize, + /// Whether nonempty decision slots are filled and no source remains pending. + /// This is local preparation status, never independent approval or an applied write. pub complete: bool, } @@ -1237,10 +1242,14 @@ pub fn assess_steward_review_progress( library_version: worksheet.library_version, rule_revision: worksheet.rule_revision.clone(), snapshot_digest: worksheet.snapshot_digest.clone(), + proposal_digest: worksheet.proposal_digest.clone(), total_count, decided_count, remaining_count, - complete: total_count > 0 && remaining_count == 0, + pending_source_count: report.pending_source_item_keys.len(), + complete: total_count > 0 + && remaining_count == 0 + && report.pending_source_item_keys.is_empty(), }) } @@ -1251,6 +1260,7 @@ fn validate_steward_review_worksheet_against( if worksheet.library_version != expected.library_version || worksheet.rule_revision != expected.rule_revision || worksheet.snapshot_digest != expected.snapshot_digest + || worksheet.proposal_digest != expected.proposal_digest || worksheet.snapshot_items != expected.snapshot_items || worksheet.decisions.len() != expected.decisions.len() || worksheet From b2b0ef44a4f4cebb9a212935b1427c484c11b225 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:45:14 +0900 Subject: [PATCH 30/32] test(zotero): keep filled campaigns pending unresolved sources --- .../tests/steward_review_progress.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/conceptweave-zotero/tests/steward_review_progress.rs b/crates/conceptweave-zotero/tests/steward_review_progress.rs index cec1518f..1af121ef 100644 --- a/crates/conceptweave-zotero/tests/steward_review_progress.rs +++ b/crates/conceptweave-zotero/tests/steward_review_progress.rs @@ -165,13 +165,21 @@ fn progress_preserves_pending_source_scope_and_opaque_identity() { tags: vec![], }, }; - let report = classify_snapshot("9.0.6".into(), None, 42, vec![source]); - let worksheet = build_steward_review_worksheet(&report).unwrap(); + let paper: ZoteroItem = serde_json::from_value(serde_json::json!({ + "key": "PAPER", "version": 1, + "data": {"itemType": "book", "title": "ontology learning"} + })) + .unwrap(); + let report = classify_snapshot("9.0.6".into(), None, 42, vec![source, paper]); + let mut worksheet = build_steward_review_worksheet(&report).unwrap(); + worksheet.decisions[0].reviewed_disposition = Some(Disposition::Generation); let progress = assess_steward_review_progress(&report, &worksheet).unwrap(); let json = serde_json::to_value(&progress).unwrap(); assert_eq!(json["pending_source_count"], 1); assert_eq!(json["proposal_digest"], worksheet.proposal_digest); assert!(!progress.complete); - assert_eq!(progress.total_count, 0); + assert_eq!(progress.total_count, 1); + assert_eq!(progress.decided_count, 1); + assert_eq!(progress.remaining_count, 0); assert!(!json.to_string().contains("PRIVATE_SOURCE")); } From 236a9db2a8b52a6d07fe2df6c3e1b8291feda5a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:47:23 +0900 Subject: [PATCH 31/32] docs(zotero): define content-bound review preparation progress --- docs/PRD.md | 2 +- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index e4869b40..90e0c65f 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -89,7 +89,7 @@ The Zotero 10+ adapter can accept a caller-owned API key and server identity at A local steward worksheet must bind the library version, rule revision, complete raw-snapshot digest, current proposal-and-retained-source digest, every observed parent/child item revision, and one blank decision slot per classified bibliographic item. It repeats item identity, proposal, and abstention reason only; titles, abstracts, tags, collections, and matched evidence remain in the separate sensitive report. Shared inventory validation rejects omitted source records, hidden pending relationships and inconsistent identity before construction. Valid unresolved sources do not prevent starting review, but prevent claiming completion. Old worksheets without the content binding require regeneration, never automatic approval backfill. After every decision is filled, worksheet finalization must verify the governance receipt coordinates, unique item identities and revisions, proposal/abstention consistency, and non-abstention truth labels before producing a reviewed golden set. Missing decisions remain incomplete and cannot reach external approval verification. Operators must be able to finalize the saved report, completed worksheet, and approval receipt offline without rereading mutable Zotero state. Every input must be a distinct owner-only file identity, not merely a differently spelled path, and the new golden-set output must use a separate path; invalid, oversized, linked, or shared inputs fail closed. -During the human review campaign, operators must be able to validate a partially completed worksheet against its original report and persist aggregate progress without an approval receipt. Progress reports only total, decided, and remaining counts plus a syntactic-completion flag; it never suggests labels or claims correctness, approval, or publication authority. An empty campaign is not complete. +During the human review campaign, operators must be able to validate a partially completed worksheet against its original report and persist aggregate progress without an approval receipt. Progress binds current proposal and retained metadata identity and reports bibliographic total, decided and remaining counts alongside unresolved source count. Local preparation is complete only for a nonempty fully decided worksheet with no unresolved sources. Filled paper decisions do not hide pending attachments, notes or disconnected ancestry. Progress never claims correctness, independent approval, applied reclassification or publication authority. An empty campaign is not complete. The worksheet's own required content identity must match the current report independently of the supplied receipt. Blank identity is invalid; a stale or replaced identity is a snapshot mismatch. Conversion only prepares input for independent verification. Unresolved sources can remain in locally prepared review data, but prevent whole-library completion; refreshing local digests cannot renew an independently issued approval. Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. diff --git a/docs/TRD.md b/docs/TRD.md index 1aecfc5b..5452a797 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -116,7 +116,7 @@ The finalization function consumes the filled worksheet plus a governance approv 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 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. `conceptweave-zotero --finalize /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/APPROVAL.json /tmp/GOLDEN.json` performs that offline transition. All four path arguments must differ, and the three opened inputs must also have distinct Unix device/inode identities so alternate spellings cannot collapse artifacts. Inputs must be regular, single-link files with exact `0600` permissions, no larger than 16 MiB, and direct children of a canonical system temporary directory; the golden set uses the existing create-new `0600` output boundary. Finalization reads no Zotero state and applies the existing report-bound worksheet and approval validation before writing. -`conceptweave-zotero --review-progress /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/PROGRESS.json` reuses the same private input/output boundary and canonical worksheet comparison for an incremental checkpoint. Its report and worksheet paths and opened Unix device/inode identities must differ. It accepts blank decisions, counts only explicit non-abstention steward decisions, rejects missing, extra, reordered, shifted, or tampered decisions, and emits only the library/rule/digest coordinates, total, decided, remaining, and `complete`. `complete` requires a nonempty fully decided worksheet and is coverage evidence only; no authority verifier or Zotero call runs. +`conceptweave-zotero --review-progress /tmp/REPORT.json /tmp/WORKSHEET.json /tmp/PROGRESS.json` reuses private artifact admission and canonical worksheet comparison. Its input paths and opened device/inode identities must differ. Blank decisions are accepted; missing, extra, reordered, shifted or tampered decisions fail. Shared comparison requires the recomputed proposal digest, rejecting blank or stale binding without backfill. Output contains library/rule/snapshot/proposal coordinates, bibliographic total/decided/remaining counts, `pending_source_count` and `complete`. Completion requires nonempty fully decided slots and zero recomputed pending sources. This is local preparation only; no authority verifier or Zotero call runs. Existing finalization error precedence remains unchanged. Offline input admission pins the checked canonical parent plus file name for metadata and opening. Unix opening refuses final-component symlinks with `O_NOFOLLOW` and uses `O_NONBLOCK` so a raced FIFO cannot wait for a writer before device/inode validation rejects the replacement; a pre-open pathname check alone is insufficient. Regular-file reads remain bounded as before. Paired export retains the shared canonical-destination check before capture and serializes both artifacts before writing. Failures may leave private partial files and never trigger pathname cleanup or implicit buffer-flush retry. Finalized metadata remains unverified until the independent whole-set approval boundary succeeds. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 5b585508..deacedd5 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -120,6 +120,12 @@ GREEN source `f6735b585022aac1c8ceff86c150d9b64fd77ec2` passed 68 tests across 1 ## Alternatives considered +### Proposed review-progress scope amendment — September 7 + +PR30 exposes incremental local progress and extracts shared worksheet comparison. Normal merge `7251238` inherits repaired source identity, finalization and private input boundaries while preserving the child's coverage improvements. The extracted comparator omitted the required proposal digest, allowing old worksheet progress after changed content. RED `4099434` compiled with one passing and two failing tests: blank binding was admitted and pending/content identity fields were missing. `bee32f4` adds one shared digest comparison and includes the opaque proposal binding and pending count in existing aggregate output. `b2b0ef4` verifies one filled paper decision plus an unresolved source stays incomplete, preserving bibliographic counts and excluding the private source key. + +We reject snapshot-only identity because restored proposal and retained metadata can change under that coordinate. We reject treating nonbibliographic sources as paper decisions because their disposition is not inferred truth. We report both scopes, require zero pending sources for local completion, and retain separate approval and write gates. No new digest, source-text export or authority mechanism is added. Existing early finalization checks preserve error precedence. The stricter completion meaning corrects consumers that equated filled slots with complete source scope; later consumers must inherit it before claiming preparation completion. Protected review and release remain pending. + ### September 6 duplicate source-scope admission (Proposed) PR #12 already binds exact candidate membership and complete item revisions in `ReviewedDuplicateMergeSet`; the prior audit-owner concern about unbound duplicate authority therefore does not describe this consumer. Its real remaining gap was that retained metadata and inventory were absent from its receipt, and external verification preceded local decision checks. RED `4656d6b` reproduced missing legacy scope binding, malformed inventory accepted, and altered standalone evidence reaching governance. From ee1ac9925c5287e9f10c2e9581b7cf513b170bd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 00:47:50 +0900 Subject: [PATCH 32/32] docs(zotero): record exact progress scope verification --- 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 92a62a8a..4b8c346e 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 PR30 content-bound progress repair + +Original PR30 `a11e889d1680ab4d91f3565e3debf7ed0f10ba23` passed 156 tests/32 suites. Normal merge `7251238` retains it and PR29 `e21f14fbb4954762ffc97521af9a6cdd9982c630`; integrated tests passed 200/32. Child private-helper coverage improvements remain, combined with parent FIFO/source/approval/output safety. RED `4099434` compiled with one passing and two failing progress tests: blank proposal binding was accepted and pending/content identity fields were absent. `bee32f4` compares the existing digest in the shared worksheet validator and adds opaque proposal identity plus pending count to aggregate progress. `b2b0ef4` proves filled bibliographic slots do not hide an unresolved standalone source. Independent bounded review found no collateral finalization error-precedence or privacy regression. + +Final source `b2b0ef4` passes 202 tests/32 suites including three doctests, strict Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged coverage gate passes 328/328 reported functions, 2,905/2,905 normalized regions and 518/518 normalized branches. Raw 3,923/3,985 lines, 6,065/6,172 regions and 473/518 branches are not 100%. Logs: `/tmp/conceptweave-pr30-{baseline,integrated,progress-red,verified,clippy,rustdoc,coverage}.log`. PRD/TRD/Proposed ADR0006 explicitly distinguish bibliographic slot counts, pending source scope, local preparation, independent approval and applied reclassification. No new digest, dependency or authority issuer was added. + +Root and later consumers must inherit content binding, pending completion semantics and prior FIFO protection. Actual decisions and independent approvals remain 0/3,715 plus four unresolved standalone sources. Synthetic unit fixtures do not count as research review. Native Visual Inspection was retried, but the Mac is locked and no fresh screenshot exists. Keep Draft; no real Zotero write, hosted GREEN, protected merge or release is claimed. + ### September 7 PR29 offline finalization continuity repair Original PR29 `f73705e15f1236fa8bd34fec032bc78d9b57760c` passed 149 tests/28 suites. Normal merge `4845200` retains it and repaired PR28 `63eb0f116408372675f132b9836fe7be4bdd7134`; integrated tests passed 190/28. Offline finalization remains local unverified metadata output. The request enum retains parent canonical-pair admission before capture, both serializations before writes and no pathname cleanup on failure. Complete source/worksheet/approval binding comes from the inherited shared validation, not a new authority issuer.