From 6193dff725e960248b91ef4e12dec5c916dcbfcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:40:29 +0900 Subject: [PATCH 1/9] test(zotero): require owner-only decision patch CLI --- crates/conceptweave-zotero/src/main.rs | 46 ++++++++++ .../tests/steward_decision_cli.rs | 90 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 crates/conceptweave-zotero/tests/steward_decision_cli.rs diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 06988eff..ddeaca78 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -506,6 +506,52 @@ mod tests { ); } + #[test] + fn apply_decision_patch_mode_requires_four_distinct_artifact_paths() { + let report = "/tmp/report.json"; + let worksheet = "/tmp/worksheet.json"; + let patch = "/tmp/patch.json"; + let output = "/tmp/updated-worksheet.json"; + assert_eq!( + parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch, output]), + Ok(OutputRequest::ApplyDecisionPatch { + report: report.to_owned(), + worksheet: worksheet.to_owned(), + patch: patch.to_owned(), + output: output.to_owned(), + }) + ); + assert!(parse_output_request(vec!["--apply-decision-patch"]).is_err()); + assert!(parse_output_request(vec!["--apply-decision-patch", report]).is_err()); + assert!(parse_output_request(vec!["--apply-decision-patch", report, worksheet]).is_err()); + assert!( + parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch]).is_err() + ); + assert!( + parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch, report]) + .is_err() + ); + assert!( + parse_output_request(vec!["--apply-decision-patch", report, worksheet, worksheet, output]) + .is_err() + ); + assert!( + parse_output_request(vec!["--apply-decision-patch", report, report, patch, output]) + .is_err() + ); + assert!( + parse_output_request(vec![ + "--apply-decision-patch", + report, + worksheet, + patch, + output, + "extra", + ]) + .is_err() + ); + } + #[cfg(unix)] #[test] fn private_json_input_is_owner_only_regular_bounded_and_valid() { diff --git a/crates/conceptweave-zotero/tests/steward_decision_cli.rs b/crates/conceptweave-zotero/tests/steward_decision_cli.rs new file mode 100644 index 00000000..9739a908 --- /dev/null +++ b/crates/conceptweave-zotero/tests/steward_decision_cli.rs @@ -0,0 +1,90 @@ +#![cfg(unix)] + +use conceptweave_zotero::{ + Disposition, ItemData, StewardDecisionPatch, StewardDecisionUpdate, StewardReviewWorksheet, + ZoteroItem, build_steward_review_worksheet, classify_snapshot, +}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ZoteroItem { + key: "ITEM".into(), + version: 7, + data: ItemData { + item_type: "book".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }], + ); + let worksheet = build_steward_review_worksheet(&report).unwrap(); + let patch = StewardDecisionPatch { + library_version: report.library_version, + rule_revision: report.rule_revision.clone(), + snapshot_digest: report.snapshot_digest.clone(), + decisions: vec![StewardDecisionUpdate { + item_key: "ITEM".into(), + item_version: 7, + reviewed_disposition: Disposition::AlignmentVersioning, + }], + }; + + let report_path = private_input("decision-cli-report", &report); + let worksheet_path = private_input("decision-cli-worksheet", &worksheet); + let patch_path = private_input("decision-cli-patch", &patch); + let output_path = temp_path("decision-cli-output"); + let _ = fs::remove_file(&output_path); + + let status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) + .args([ + "--apply-decision-patch", + report_path.to_str().unwrap(), + worksheet_path.to_str().unwrap(), + patch_path.to_str().unwrap(), + output_path.to_str().unwrap(), + ]) + .status() + .unwrap(); + + assert!(status.success()); + let updated: StewardReviewWorksheet = + serde_json::from_slice(&fs::read(&output_path).unwrap()).unwrap(); + assert_eq!( + updated.decisions[0].reviewed_disposition, + Some(Disposition::AlignmentVersioning) + ); + assert_eq!( + fs::metadata(&output_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + for path in [report_path, worksheet_path, patch_path, output_path] { + fs::remove_file(path).unwrap(); + } +} + +fn private_input(name: &str, value: &impl serde::Serialize) -> std::path::PathBuf { + let path = temp_path(name); + let _ = fs::remove_file(&path); + fs::write(&path, serde_json::to_vec(value).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + path +} + +fn temp_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "conceptweave-zotero-{}-{name}.json", + std::process::id() + )) +} From fd7b2950b2e57153a13387a4fd1c02dfd15c84ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:41:01 +0900 Subject: [PATCH 2/9] feat(zotero): apply steward decision patches offline --- crates/conceptweave-zotero/src/main.rs | 68 ++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index ddeaca78..8b9230f1 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -2,9 +2,9 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] use conceptweave_zotero::{ - ClassificationReport, GoldenSetApproval, StewardReviewWorksheet, - assess_steward_review_progress, build_steward_review_worksheet, read_local_snapshot, - reviewed_golden_set_from_worksheet, + ClassificationReport, GoldenSetApproval, StewardDecisionPatch, StewardReviewWorksheet, + apply_steward_decision_patch, assess_steward_review_progress, build_steward_review_worksheet, + read_local_snapshot, reviewed_golden_set_from_worksheet, }; use serde::de::DeserializeOwned; use std::collections::BTreeSet; @@ -13,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 | --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 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 | --apply-decision-patch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json /tmp/PATCH.json /tmp/UPDATED_WORKSHEET.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)] @@ -28,6 +28,12 @@ enum OutputRequest { worksheet: String, output: String, }, + ApplyDecisionPatch { + report: String, + worksheet: String, + patch: String, + output: String, + }, Finalize { report: String, worksheet: String, @@ -73,6 +79,36 @@ where worksheet, output, } + } else if first == "--apply-decision-patch" { + let report = args + .next() + .ok_or("--apply-decision-patch requires four artifact paths")?; + let worksheet = args + .next() + .ok_or("--apply-decision-patch requires four artifact paths")?; + let patch = args + .next() + .ok_or("--apply-decision-patch requires four artifact paths")?; + let output = args + .next() + .ok_or("--apply-decision-patch requires four artifact paths")?; + if BTreeSet::from([ + report.as_str(), + worksheet.as_str(), + patch.as_str(), + output.as_str(), + ]) + .len() + != 4 + { + return Err("decision patch artifact paths must differ"); + } + OutputRequest::ApplyDecisionPatch { + report, + worksheet, + patch, + output, + } } else if first == "--finalize" { let report = args .next() @@ -387,6 +423,30 @@ fn main() -> Result<(), Box> { let progress = assess_steward_review_progress(&report, &worksheet)?; write_private_output(&output, &serde_json::to_vec_pretty(&progress)?)?; } + OutputRequest::ApplyDecisionPatch { + report, + worksheet, + patch, + output, + } => { + let output = validate_output_path(&output)?; + let (report, report_identity): (ClassificationReport, _) = + read_private_json(&report).map_err(|error| label_input("report", error))?; + let (worksheet, worksheet_identity): (StewardReviewWorksheet, _) = + read_private_json(&worksheet).map_err(|error| label_input("worksheet", error))?; + let (patch, patch_identity): (StewardDecisionPatch, _) = + read_private_json(&patch).map_err(|error| label_input("decision patch", error))?; + if BTreeSet::from([report_identity, worksheet_identity, patch_identity]).len() != 3 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "decision patch inputs must be distinct files", + ) + .into()); + } + let updated = apply_steward_decision_patch(&report, &worksheet, &patch)?; + let content = serde_json::to_vec_pretty(&updated)?; + write_private_output(&output, &content)?; + } OutputRequest::Finalize { report, worksheet, From d5042b499bac8c509742c56413c65617aee65a04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:41:50 +0900 Subject: [PATCH 3/9] test(zotero): prove decision patch artifact safety --- .../tests/finalization_artifact_identity.rs | 14 +++ .../tests/steward_decision_cli.rs | 92 ++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs b/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs index 9c7d30c4..691e701a 100644 --- a/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs +++ b/crates/conceptweave-zotero/tests/finalization_artifact_identity.rs @@ -94,6 +94,16 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() { ]) .status() .unwrap(); + let patch_status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) + .args([ + "--apply-decision-patch", + &report_path, + &worksheet_path, + &approval_path, + output.to_str().unwrap(), + ]) + .status() + .unwrap(); let _ = fs::remove_file(&input); let _ = fs::remove_file(&output); @@ -105,4 +115,8 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() { !progress_status.success(), "progress must reject two path spellings that resolve to one input artifact" ); + assert!( + !patch_status.success(), + "decision patching must reject path spellings that resolve to one input artifact" + ); } diff --git a/crates/conceptweave-zotero/tests/steward_decision_cli.rs b/crates/conceptweave-zotero/tests/steward_decision_cli.rs index 9739a908..9752be28 100644 --- a/crates/conceptweave-zotero/tests/steward_decision_cli.rs +++ b/crates/conceptweave-zotero/tests/steward_decision_cli.rs @@ -44,7 +44,9 @@ fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { let worksheet_path = private_input("decision-cli-worksheet", &worksheet); let patch_path = private_input("decision-cli-patch", &patch); let output_path = temp_path("decision-cli-output"); + let replay_path = temp_path("decision-cli-replay"); let _ = fs::remove_file(&output_path); + let _ = fs::remove_file(&replay_path); let status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) .args([ @@ -69,11 +71,99 @@ fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { 0o600 ); - for path in [report_path, worksheet_path, patch_path, output_path] { + let replay_status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) + .args([ + "--apply-decision-patch", + report_path.to_str().unwrap(), + output_path.to_str().unwrap(), + patch_path.to_str().unwrap(), + replay_path.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(replay_status.success()); + assert_eq!(fs::read(&replay_path).unwrap(), fs::read(&output_path).unwrap()); + + for path in [ + report_path, + worksheet_path, + patch_path, + output_path, + replay_path, + ] { fs::remove_file(path).unwrap(); } } +#[test] +fn decision_patch_cli_never_overwrites_or_emits_invalid_work() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ZoteroItem { + key: "ITEM".into(), + version: 7, + data: ItemData { + item_type: "book".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }], + ); + let worksheet = build_steward_review_worksheet(&report).unwrap(); + let invalid_patch = StewardDecisionPatch { + library_version: report.library_version + 1, + rule_revision: report.rule_revision.clone(), + snapshot_digest: report.snapshot_digest.clone(), + decisions: vec![StewardDecisionUpdate { + item_key: "ITEM".into(), + item_version: 7, + reviewed_disposition: Disposition::OutOfScope, + }], + }; + let report_path = private_input("decision-invalid-report", &report); + let worksheet_path = private_input("decision-invalid-worksheet", &worksheet); + let patch_path = private_input("decision-invalid-patch", &invalid_patch); + let absent_output = temp_path("decision-invalid-output"); + let existing_output = temp_path("decision-existing-output"); + let _ = fs::remove_file(&absent_output); + let _ = fs::remove_file(&existing_output); + + assert!(!run_patch(&report_path, &worksheet_path, &patch_path, &absent_output).success()); + assert!(!absent_output.exists()); + + fs::write(&existing_output, b"preserve me").unwrap(); + assert!(!run_patch(&report_path, &worksheet_path, &patch_path, &existing_output).success()); + assert_eq!(fs::read(&existing_output).unwrap(), b"preserve me"); + + for path in [report_path, worksheet_path, patch_path, existing_output] { + fs::remove_file(path).unwrap(); + } +} + +fn run_patch( + report: &std::path::Path, + worksheet: &std::path::Path, + patch: &std::path::Path, + output: &std::path::Path, +) -> std::process::ExitStatus { + Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero")) + .args([ + "--apply-decision-patch", + report.to_str().unwrap(), + worksheet.to_str().unwrap(), + patch.to_str().unwrap(), + output.to_str().unwrap(), + ]) + .status() + .unwrap() +} + fn private_input(name: &str, value: &impl serde::Serialize) -> std::path::PathBuf { let path = temp_path(name); let _ = fs::remove_file(&path); From cf660fdc14c2b0f75651892ccc35739a80edebab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:42:42 +0900 Subject: [PATCH 4/9] docs(zotero): define offline decision patch workflow --- README.md | 8 ++++++++ docs/PRD.md | 1 + docs/TRD.md | 4 +++- docs/adr/0006-zotero-research-intake.md | 2 ++ docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b316086f..08ca43d8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ cargo +1.98.0 run --bin conceptweave-zotero -- /tmp/conceptweave-zotero-classifi The command reads one stable library-version snapshot and creates a local, reviewable JSON report. On Unix, output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory; the CLI fails closed on other platforms. The command never changes Zotero records. +Apply a small steward-reviewed decision patch to a new worksheet without overwriting the current one: + +```sh +cargo +1.98.0 run --bin conceptweave-zotero -- --apply-decision-patch /tmp/report.json /tmp/current-worksheet.json /tmp/patch.json /tmp/updated-worksheet.json +``` + +All three inputs must be separate owner-only files from the same immutable snapshot. The output is create-new and owner-only; this offline step neither changes Zotero nor grants governance approval. + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave) **Automatic, evidence-bound ontology and semantic-layer engineering for governed enterprise meaning.** diff --git a/docs/PRD.md b/docs/PRD.md index 80c55510..13827962 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -73,6 +73,7 @@ Operators must be able to finalize the saved report, completed worksheet, and ap 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. Operators must be able to accumulate small steward-reviewed decision sets into the canonical worksheet without hand-merging the complete JSON document. Each decision patch binds the original library version, classifier revision, snapshot digest, item key, and item revision. Empty, duplicate, unknown, stale, or abstention decisions fail atomically. Reapplying the same decision is idempotent; a different decision cannot overwrite existing review work. Applying a patch does not confer approval or record publication authority. +The offline CLI must read the saved report, current worksheet, and decision patch as three distinct owner-only file identities and create a separate updated worksheet. It must never overwrite the current worksheet, reread Zotero, or emit output after invalid input. 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 0a7d4e58..3caeedfc 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -74,7 +74,9 @@ The owner-only report uses owned JSON values and supports lossless deserializati `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. -`apply_steward_decision_patch` first rebuilds the canonical worksheet from the saved report and validates the complete current worksheet, including rejection of any pre-existing reviewed abstention. It then validates one nonempty patch against the same library version, rule revision, and snapshot digest; every update must name one unique canonical item key at its exact revision and supply a non-abstention disposition. Updates apply to a clone, so any duplicate, unknown, stale, or conflicting decision rejects the whole patch without partial state. An identical existing decision is accepted idempotently. This library contract is the owner boundary for a later private CLI; no new artifact or approval authority is implied yet. +`apply_steward_decision_patch` first rebuilds the canonical worksheet from the saved report and validates the complete current worksheet, including rejection of any pre-existing reviewed abstention. It then validates one nonempty patch against the same library version, rule revision, and snapshot digest; every update must name one unique canonical item key at its exact revision and supply a non-abstention disposition. Updates apply to a clone, so any duplicate, unknown, stale, or conflicting decision rejects the whole patch without partial state. An identical existing decision is accepted idempotently. + +`conceptweave-zotero --apply-decision-patch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json /tmp/PATCH.json /tmp/UPDATED_WORKSHEET.json` exposes that contract through the existing private artifact boundary. Four textual paths and all three opened input device/inode identities must differ. Each input is bounded, regular, single-link, exact `0600`, and opened without following the final symlink; the output is serialized before its create-new `0600` write. Invalid input leaves no output, an existing output is preserved, and identical replay may create another semantically identical worksheet. No Zotero read or authority verifier 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/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 3d54b375..501aa0fe 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -34,6 +34,8 @@ The same offline boundary may emit an aggregate progress checkpoint for a partia Incremental steward work is integrated by a snapshot-bound decision patch rather than editing or merging the complete worksheet structure. The patch carries only library/rule/digest coordinates and unique item key/version/disposition updates. The canonical report and current worksheet are revalidated first; applying to a clone makes invalid or conflicting batches atomic failures, while an identical replay is idempotent. We reject direct in-place mutation and last-writer-wins merging because either can silently discard concurrent review work. The patch remains local review input and does not mint governance authority. +The offline CLI consumes the saved report, current worksheet, and patch as distinct owner-only file identities and emits only a separate create-new worksheet. Reusing the existing private artifact boundary keeps the command local, bounded, and fail-closed without introducing a second review service or repository. The CLI never rereads Zotero, overwrites the source worksheet, or verifies approval. + 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 ed730495..03aced5e 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 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. -The first campaign-integration slice now applies small snapshot-bound decision patches to the canonical worksheet. It revalidates the complete report and worksheet, rejects both pre-existing reviewed abstentions and abstentions in the incoming patch, applies updates atomically to a clone, permits identical replay, and rejects empty, duplicate, unknown, stale, or conflicting decisions. This removes unsafe manual whole-file merging without creating another aggregate or repository. No CLI has adopted the function and no live steward decision has been applied, so both unverified worksheet coverage and externally approved completion remain 0/3,715; the next Gap is the owner-only CLI path and real steward input. +The campaign integration now applies small snapshot-bound decision patches through an owner-only CLI. It revalidates the complete report and worksheet, rejects both pre-existing reviewed abstentions and abstentions in the incoming patch, applies updates atomically to a clone, permits identical replay, and rejects empty, duplicate, unknown, stale, or conflicting decisions. `--apply-decision-patch` reads three distinct bounded `0600` input identities and creates a separate `0600` worksheet without rereading Zotero or overwriting current review work. Invalid input leaves no output and an existing output remains unchanged. This removes unsafe manual whole-file merging without creating another aggregate or repository. No live steward decision has been applied, so both unverified worksheet coverage and externally approved completion remain 0/3,715; the next Gap is real steward input and measured campaign progress. 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 unverified worksheet coverage of 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. Externally approved labels remain independently 0/3,715. From 8f5dee8b7cb49733162d5daa87e145dd1853c36a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:42:53 +0900 Subject: [PATCH 5/9] style(zotero): format decision patch CLI tests --- crates/conceptweave-zotero/src/main.rs | 38 +++++++++++++++---- .../tests/steward_decision_cli.rs | 5 ++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 8b9230f1..c800b869 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -573,7 +573,13 @@ mod tests { let patch = "/tmp/patch.json"; let output = "/tmp/updated-worksheet.json"; assert_eq!( - parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch, output]), + parse_output_request(vec![ + "--apply-decision-patch", + report, + worksheet, + patch, + output + ]), Ok(OutputRequest::ApplyDecisionPatch { report: report.to_owned(), worksheet: worksheet.to_owned(), @@ -588,16 +594,34 @@ mod tests { parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch]).is_err() ); assert!( - parse_output_request(vec!["--apply-decision-patch", report, worksheet, patch, report]) - .is_err() + parse_output_request(vec![ + "--apply-decision-patch", + report, + worksheet, + patch, + report + ]) + .is_err() ); assert!( - parse_output_request(vec!["--apply-decision-patch", report, worksheet, worksheet, output]) - .is_err() + parse_output_request(vec![ + "--apply-decision-patch", + report, + worksheet, + worksheet, + output + ]) + .is_err() ); assert!( - parse_output_request(vec!["--apply-decision-patch", report, report, patch, output]) - .is_err() + parse_output_request(vec![ + "--apply-decision-patch", + report, + report, + patch, + output + ]) + .is_err() ); assert!( parse_output_request(vec![ diff --git a/crates/conceptweave-zotero/tests/steward_decision_cli.rs b/crates/conceptweave-zotero/tests/steward_decision_cli.rs index 9752be28..98e66943 100644 --- a/crates/conceptweave-zotero/tests/steward_decision_cli.rs +++ b/crates/conceptweave-zotero/tests/steward_decision_cli.rs @@ -82,7 +82,10 @@ fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { .status() .unwrap(); assert!(replay_status.success()); - assert_eq!(fs::read(&replay_path).unwrap(), fs::read(&output_path).unwrap()); + assert_eq!( + fs::read(&replay_path).unwrap(), + fs::read(&output_path).unwrap() + ); for path in [ report_path, From 8c9eac55c904916334637c03c6f9c3621a2084bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:09:00 +0900 Subject: [PATCH 6/9] test(research): adopt captured-source decision CLI fixtures Signed-off-by: Seongho Bae --- crates/conceptweave-zotero/tests/steward_decision_cli.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/conceptweave-zotero/tests/steward_decision_cli.rs b/crates/conceptweave-zotero/tests/steward_decision_cli.rs index 98e66943..de1ccf81 100644 --- a/crates/conceptweave-zotero/tests/steward_decision_cli.rs +++ b/crates/conceptweave-zotero/tests/steward_decision_cli.rs @@ -15,6 +15,7 @@ fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { None, 42, vec![ZoteroItem { + source_record: None, key: "ITEM".into(), version: 7, data: ItemData { @@ -105,6 +106,7 @@ fn decision_patch_cli_never_overwrites_or_emits_invalid_work() { None, 42, vec![ZoteroItem { + source_record: None, key: "ITEM".into(), version: 7, data: ItemData { From d3a2c7ae7f54fa703c54f4cbe7a1d609817f6b66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:00:48 +0900 Subject: [PATCH 7/9] test(zotero): verify CLI binding refusal and valid output preservation --- .../tests/steward_decision_cli.rs | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/conceptweave-zotero/tests/steward_decision_cli.rs b/crates/conceptweave-zotero/tests/steward_decision_cli.rs index f582d33a..bf43710d 100644 --- a/crates/conceptweave-zotero/tests/steward_decision_cli.rs +++ b/crates/conceptweave-zotero/tests/steward_decision_cli.rs @@ -10,7 +10,7 @@ use std::process::Command; #[test] fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { - let report = classify_snapshot( + let mut report = classify_snapshot( "9.0.6".into(), None, 42, @@ -89,6 +89,56 @@ fn decision_patch_cli_writes_one_owner_only_updated_worksheet() { fs::read(&output_path).unwrap() ); + let original_output = fs::read(&output_path).unwrap(); + assert!(!run_patch(&report_path, &worksheet_path, &patch_path, &output_path).success()); + assert_eq!(fs::read(&output_path).unwrap(), original_output); + + let rejected_output = temp_path("decision-binding-rejected"); + assert!(!rejected_output.exists()); + for missing in [true, false] { + let mut value = serde_json::to_value(&patch).unwrap(); + if missing { + value.as_object_mut().unwrap().remove("proposal_digest"); + } else { + value["proposal_digest"] = serde_json::json!(""); + } + let invalid_path = private_input("decision-binding-invalid", &value); + assert!( + !run_patch( + &report_path, + &worksheet_path, + &invalid_path, + &rejected_output + ) + .success() + ); + assert!(!rejected_output.exists()); + fs::remove_file(invalid_path).unwrap(); + } + + report.classified_items[0] + .title + .push_str(" changed review context"); + let current = build_steward_review_worksheet(&report).unwrap(); + let current_report_path = private_input("decision-binding-current-report", &report); + let current_worksheet_path = private_input("decision-binding-current-worksheet", ¤t); + let original_patch = fs::read(&patch_path).unwrap(); + let current_bytes = fs::read(¤t_worksheet_path).unwrap(); + assert!( + !run_patch( + ¤t_report_path, + ¤t_worksheet_path, + &patch_path, + &rejected_output + ) + .success() + ); + assert!(!rejected_output.exists()); + assert_eq!(fs::read(&patch_path).unwrap(), original_patch); + assert_eq!(fs::read(¤t_worksheet_path).unwrap(), current_bytes); + fs::remove_file(current_report_path).unwrap(); + fs::remove_file(current_worksheet_path).unwrap(); + for path in [ report_path, worksheet_path, From a93b62ac86c9da2e3131e43a232c972388ec1795 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:01:37 +0900 Subject: [PATCH 8/9] docs(zotero): record thin decision CLI contract verification --- README.md | 2 +- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 54a66e45..50838acd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Apply a small steward-reviewed decision patch to a new worksheet without overwri cargo +1.98.0 run --bin conceptweave-zotero -- --apply-decision-patch /tmp/report.json /tmp/current-worksheet.json /tmp/patch.json /tmp/updated-worksheet.json ``` -All three inputs must be separate owner-only files from the same immutable snapshot. The output is create-new and owner-only; this offline step neither changes Zotero nor grants governance approval. +All three inputs must be separate owner-only files bound to the same snapshot and reviewed content. Older patches without content identity must be regenerated from the evidence actually reviewed; a new worksheet cannot renew an old patch. The output is a new owner-only file. This offline step neither changes Zotero nor grants governance approval. If file-permission setup fails, the command stops before writing report content. An empty file may remain; inspect it before removing it. The command does not diff --git a/docs/TRD.md b/docs/TRD.md index 2893d1ba..0ac6f390 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -121,7 +121,7 @@ The owner-only report uses owned JSON values and supports lossless deserializati 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. `apply_steward_decision_patch` rebuilds the canonical worksheet and validates the complete current worksheet, including rejection of pre-existing reviewed abstention. It validates a nonempty patch against library version, rule revision, snapshot digest and its own required `proposal_digest`; a fresh worksheet cannot renew an older patch's reviewed content. Missing binding fails deserialization; blank or stale binding fails before updates, without backfill. Every update names a unique canonical item key and exact revision with a non-abstention disposition. Updates apply to a clone, so duplicate, unknown, stale or conflicting decisions reject the whole batch without partial state. Identical replay remains idempotent. This is the owner boundary for a later private CLI, not independent approval or full-text/write authority. -`conceptweave-zotero --apply-decision-patch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json /tmp/PATCH.json /tmp/UPDATED_WORKSHEET.json` exposes that contract through the existing private artifact boundary. Four textual paths and all three opened input device/inode identities must differ. Each input is bounded, regular, single-link, exact `0600`, and opened without following the final symlink; the output is serialized before its create-new `0600` write. Invalid input leaves no output, an existing output is preserved, and identical replay may create another semantically identical worksheet. No Zotero read or authority verifier runs. +`conceptweave-zotero --apply-decision-patch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json /tmp/PATCH.json /tmp/UPDATED_WORKSHEET.json` exposes that contract through the existing private artifact boundary. Four textual paths and all three opened input device/inode identities must differ. Inputs are bounded, regular, single-link, exact `0600`, opened through the checked canonical parent with no-follow/nonblocking flags. The required patch proposal binding is deserialized and passed unchanged to the owner validator, never synthesized by the CLI. Validation failure leaves no output; existing output is preserved even for a valid patch. Serialization precedes create-new output, while a later write failure may retain private partial files without cleanup or retry. Identical replay may create another equal worksheet. No Zotero read or authority verifier 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/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 35423504..a41a7f7a 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -124,6 +124,12 @@ GREEN source `f6735b585022aac1c8ceff86c150d9b64fd77ec2` passed 68 tests across 1 ## Alternatives considered +### Proposed decision CLI integration amendment — September 7 + +PR32 exposes the patch owner as an offline command. Normal merge `b7cae29` retains the original CLI delta and PR31's required proposal binding with prior private-file protections. The existing arm reads three distinct private inputs, delegates the typed patch unchanged to the canonical validator, serializes the returned worksheet and creates a new output. We retain that implementation rather than add another validator or infer a patch digest in the CLI. Two synthetic fixture constructors adopt the required binding; no production source is copied or new dependency introduced. + +Test commit `d3a2c7a` extends the real binary contract: a valid patch cannot overwrite existing output; missing and blank binding produce no output; an old patch fails against changed report content even with a fresh valid worksheet; the rejected operation preserves patch and worksheet bytes. Successful owner-only output and identical replay remain covered. These are inherited-owner GREEN checks, not a newly reproduced CLI defect or actual steward review. Rejected alternatives are duplicate CLI validation, automatic legacy binding repair and cleanup of failed paths. Local output remains unverified metadata preparation; failures after writing starts may retain partial private files. Root and subsequent command consumers must inherit the binding and private-open repairs before release. Protected checks and independent approval remain separate. + ### Proposed decision-patch content identity amendment — September 7 PR31 accumulates local decisions without overwriting conflicts. Normal merge `ef0ce43` preserves the original patch delta and PR30's content-bound worksheet/progress contract. A valid current worksheet alone does not prove that an older patch reviewed the current content: RED `7208d80` compiled with three passing and two failing tests, accepting both an unbound serialized patch and an old patch after changed report context plus fresh worksheet generation. `89bb941` adds a required patch `proposal_digest` and compares it with the existing recomputed identity before any updates. No new hash or authority issuer is added. Missing fields fail loading; blank/stale values cannot equal the expected digest. From ea25437c54b4e452f008f6b1ccad92d5516dd7b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:03:28 +0900 Subject: [PATCH 9/9] docs(zotero): record decision CLI exact scope evidence --- docs/product-technical-gap-baseline.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1b4956a7..897e99e9 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 PR32 decision CLI continuity verification + +Original PR32 `1e711728d83efc1e60fc3d43ba0c67c467dd6a43` passed 161 tests/34 suites. Normal merge `b7cae29` retains it and PR31 `eaf248afd33dcac477daf1e3a31d79d47c5cbb69`; two synthetic patch constructors adopt required proposal identity, and integrated tests pass 210/34. The thin apply arm remains unchanged: private distinct inputs go to the canonical validator without binding backfill; output is serialized before create-new writing. No additional production defect or new RED is claimed. + +Test `d3a2c7a` extends actual binary execution: valid-patch nonoverwrite, missing/blank binding no-output rejection, stale patch against regenerated current worksheet rejection, and unchanged patch/worksheet bytes. Existing valid `0600` output and equal replay remain. Independent bounded review found no additional defect. Final source passes 210 tests/34 suites including three doctests, strict Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. Unchanged coverage passes 332/332 reported functions, 2,997/2,997 normalized regions and 548/548 normalized branches. Raw 4,060/4,122 lines, 6,228/6,335 regions and 503/548 branches are not 100%. Logs: `/tmp/conceptweave-pr32-{baseline,integrated,verified,clippy,rustdoc,coverage}.log`. + +README/TRD/Proposed ADR0006 distinguish current-content binding, no legacy backfill, pre-write rejection and possible retained partial files after write failure. Root/later consumers still require cascade adoption. Authentic decisions and independent approvals remain 0/3,715 plus four unresolved sources; synthetic unit data is not real research. Native Visual Inspection was retried, but the Mac is locked and no fresh screenshot exists. Keep Draft; no Zotero mutation, hosted GREEN, protected merge or release occurred. + ### September 7 PR31 decision-patch content repair Original PR31 `61072a70f7ec5a1fbd0b477430aacb8e770aa109` passed 158 tests/33 suites. Normal merge `ef0ce43` retains it and PR30 `ee1ac9925c5287e9f10c2e9581b7cf513b170bd7`; integrated tests passed 204/33. RED `7208d80` compiled with three passing and two failing tests: an old patch applied after changed report context and fresh worksheet generation, and serialized patches without content binding loaded. The valid-first/unknown-later batch already preserved the original worksheet. `89bb941` adds required patch proposal identity and compares it before updates, reusing the existing digest. Missing fields fail loading, stale/blank identities cannot equal the recomputed expected identity, and no automatic backfill is permitted. Independent bounded review found no additional defect; atomic failure, same-label idempotency and conflicting-decision rejection remain.