Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f653c50
test(zotero): require review batch context validation
seonghobae Sep 4, 2026
2475815
fix(zotero): validate completed review batch context
seonghobae Sep 4, 2026
1dadb21
test(zotero): require validated batch apply CLI
seonghobae Sep 4, 2026
f05523d
feat(zotero): apply validated review batches offline
seonghobae Sep 4, 2026
86fd7c4
test(zotero): reject unvalidated batch as decision patch
seonghobae Sep 4, 2026
d9fb1f9
fix(zotero): reject unvalidated review context
seonghobae Sep 4, 2026
f301796
docs(zotero): bind reviewed batches to displayed evidence
seonghobae Sep 4, 2026
b94d86a
test(zotero): cover review batch rejection paths
seonghobae Sep 4, 2026
38dcf08
test(zotero): preserve patch identity coverage
seonghobae Sep 4, 2026
96f8a0d
style(zotero): format batch apply assertion
seonghobae Sep 4, 2026
0118cfe
test(zotero): reject unknown review batch context
seonghobae Sep 4, 2026
818ad64
fix(zotero): parse review context fail closed
seonghobae Sep 4, 2026
9573e92
test(zotero): reject unknown batch fields at CLI boundary
seonghobae Sep 4, 2026
a84e6d4
docs(zotero): record strict review batch schema
seonghobae Sep 4, 2026
5325e9a
test(zotero): pin canonical output parent boundary
seonghobae Sep 4, 2026
299213a
merge(zotero): adopt current review-batch parent
seonghobae Sep 4, 2026
86288cd
fix(zotero): rebuild output path from canonical parent
seonghobae Sep 4, 2026
062a0d9
merge(zotero): adopt current review-batch parent and gap baseline
seonghobae Sep 5, 2026
22030ae
docs(research): restore measured Zotero campaign baseline
seonghobae Sep 5, 2026
7af3a38
docs(research): bind owner inventory to current Zotero evidence
seonghobae Sep 5, 2026
4055312
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
a359c5b
test(research): expect validated canonical output parent
seonghobae Sep 5, 2026
6f27da9
test(research): cover missing private output filename rejection
seonghobae Sep 5, 2026
2a75051
docs(research): refresh repaired Zotero campaign and owner census
seonghobae Sep 5, 2026
9ee4b99
docs(research): bind full-text availability and provider contract fin…
seonghobae Sep 5, 2026
a2bbe0f
docs(security): isolate Zotero diagnostic curl requests
seonghobae Sep 5, 2026
2e6448e
docs(research): distinguish retrieval attempts from returned content
seonghobae Sep 5, 2026
3f86181
merge(research): inherit canonical local transport repairs into PR #34
seonghobae Sep 5, 2026
b0119a5
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
b69f977
Merge verified private artifact boundary repair into PR 34
seonghobae Sep 5, 2026
853517c
merge(zotero): propagate validated approval ordering through PR 34
seonghobae Sep 5, 2026
b9060df
merge(research): inherit bounded metadata reads into PR #34
seonghobae Sep 6, 2026
bd8f995
merge: preserve reviewed batch validation with source scope prerequisite
seonghobae Sep 6, 2026
d1e8bb7
test: bind patch fixture without duplicating approval identity
seonghobae Sep 6, 2026
e889ac8
test: reject stale reviewed batch source scope before projection
seonghobae Sep 6, 2026
5a23660
test: construct changed source without cloning report
seonghobae Sep 6, 2026
cb8f78c
test: isolate retained context drift from raw snapshot drift
seonghobae Sep 6, 2026
28a2e28
docs: record reviewed view continuity and retained failure boundaries
seonghobae Sep 6, 2026
9eb89b8
docs: record PR34 exact view verification and remaining evidence gaps
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ All notable changes to ConceptWeave are documented here.

### Added

- Full-library research-source audit separating available text, incomplete indexing and missing material from reviewed classification; no paper is excluded because its abstract or text is unavailable.
- Initial ConceptWeave product, DDD, security, test, and operability baselines.
- Rust 1.98.0 `conceptweave-domain` foundation with evidence-bound semantic candidate contracts.
- Fail-closed Draft -> Proposed -> Validated -> Reviewed -> Published lifecycle with explicit rejection and supersession.
Expand All @@ -32,6 +33,7 @@ All notable changes to ConceptWeave are documented here.
- An explicit `--worksheet` CLI mode that writes the live worksheet with owner-only report protections.
- Fail-closed conversion from a fully decided worksheet to the existing externally verified golden-set boundary.
- Lossless owner-only classification-report deserialization for offline review finalization.
- Context-bound validation and owner-only application of completed steward review batches.

### Security

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ Create a small deterministic view of the next pending records for human review:
cargo +1.98.0 run --bin conceptweave-zotero -- --review-batch /tmp/report.json /tmp/current-worksheet.json 25 /tmp/review-batch.json
```

The batch repeats on unchanged input and is not a reservation or assignment. It contains sensitive bibliographic context, must remain owner-only, and becomes a decision patch only after a steward fills every `reviewed_disposition`.
The batch repeats on unchanged input and is not a reservation or assignment. It contains sensitive bibliographic context and must remain owner-only. After a steward fills every `reviewed_disposition`, validate the complete displayed context and create a new worksheet:

```sh
cargo +1.98.0 run --bin conceptweave-zotero -- --apply-review-batch /tmp/report.json /tmp/current-worksheet.json /tmp/review-batch.json /tmp/updated-worksheet.json
```

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave)

Expand Down
46 changes: 44 additions & 2 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub struct ItemData {

/// A Zotero item tag.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ItemTag {
/// Tag text.
pub tag: String,
Expand Down Expand Up @@ -146,6 +147,7 @@ pub enum AbstentionReason {

/// Evidence for a deterministic proposed disposition.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ClassificationEvidence {
/// Metadata fields whose values matched.
pub fields: Vec<String>,
Expand Down Expand Up @@ -1176,6 +1178,7 @@ pub struct StewardDecisionUpdate {

/// A bounded set of local steward decisions for one immutable classification snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StewardDecisionPatch {
/// Zotero library revision shared with the report and worksheet.
pub library_version: u64,
Expand All @@ -1193,7 +1196,8 @@ pub struct StewardDecisionPatch {
pub const MAX_REVIEW_BATCH_ITEMS: usize = 100;

/// One pending decision with the report context required for human review.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StewardReviewBatchDecision {
/// Stable Zotero item key used to apply the completed decision.
pub item_key: String,
Expand All @@ -1220,7 +1224,8 @@ pub struct StewardReviewBatchDecision {
}

/// Deterministic owner-only view of the next pending steward decisions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StewardReviewBatch {
/// Zotero library revision shared with the report and worksheet.
pub library_version: u64,
Expand Down Expand Up @@ -1455,6 +1460,43 @@ pub fn apply_steward_decision_patch(
Ok(updated)
}

/// Converts a completed review batch only when its presented context is unchanged.
pub fn decision_patch_from_review_batch(
report: &ClassificationReport,
worksheet: &StewardReviewWorksheet,
batch: &StewardReviewBatch,
) -> Result<StewardDecisionPatch, WorksheetError> {
if batch.decisions.is_empty() {
return Err(WorksheetError::InvalidReport);
}
let expected = build_steward_review_batch(report, worksheet, batch.decisions.len())?;
let mut reviewed_context = batch.clone();
let mut decisions = Vec::with_capacity(reviewed_context.decisions.len());
for decision in &mut reviewed_context.decisions {
let Some(reviewed_disposition) = decision.reviewed_disposition.take() else {
return Err(WorksheetError::InvalidReport);
};
if reviewed_disposition == Disposition::NeedsStewardReview {
return Err(WorksheetError::InvalidReport);
}
decisions.push(StewardDecisionUpdate {
item_key: decision.item_key.clone(),
item_version: decision.item_version,
reviewed_disposition,
});
}
if reviewed_context != expected {
return Err(WorksheetError::InvalidReport);
}
Ok(StewardDecisionPatch {
library_version: expected.library_version,
rule_revision: expected.rule_revision,
snapshot_digest: expected.snapshot_digest,
proposal_digest: expected.proposal_digest,
decisions,
})
}

fn validate_steward_review_worksheet_against(
expected: &StewardReviewWorksheet,
worksheet: &StewardReviewWorksheet,
Expand Down
122 changes: 116 additions & 6 deletions crates/conceptweave-zotero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

use conceptweave_zotero::{
ClassificationReport, GoldenSetApproval, MAX_REVIEW_BATCH_ITEMS, StewardDecisionPatch,
StewardReviewWorksheet, apply_steward_decision_patch, assess_steward_review_progress,
build_steward_review_batch, build_steward_review_worksheet, read_local_snapshot,
reviewed_golden_set_from_worksheet,
StewardReviewBatch, StewardReviewWorksheet, apply_steward_decision_patch,
assess_steward_review_progress, build_steward_review_batch, build_steward_review_worksheet,
decision_patch_from_review_batch, read_local_snapshot, reviewed_golden_set_from_worksheet,
};
use serde::de::DeserializeOwned;
use std::collections::BTreeSet;
Expand All @@ -14,7 +14,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 | --review-batch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json LIMIT /tmp/BATCH.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 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 | --review-batch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json LIMIT /tmp/BATCH.json | --apply-review-batch /tmp/REPORT.json /tmp/CURRENT_WORKSHEET.json /tmp/COMPLETED_BATCH.json /tmp/UPDATED_WORKSHEET.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)]
Expand All @@ -41,6 +41,12 @@ enum OutputRequest {
patch: String,
output: String,
},
ApplyReviewBatch {
report: String,
worksheet: String,
batch: String,
output: String,
},
Finalize {
report: String,
worksheet: String,
Expand Down Expand Up @@ -117,6 +123,36 @@ where
limit,
output,
}
} else if first == "--apply-review-batch" {
let report = args
.next()
.ok_or("--apply-review-batch requires four artifact paths")?;
let worksheet = args
.next()
.ok_or("--apply-review-batch requires four artifact paths")?;
let batch = args
.next()
.ok_or("--apply-review-batch requires four artifact paths")?;
let output = args
.next()
.ok_or("--apply-review-batch requires four artifact paths")?;
if BTreeSet::from([
report.as_str(),
worksheet.as_str(),
batch.as_str(),
output.as_str(),
])
.len()
!= 4
{
return Err("review batch application artifact paths must differ");
}
OutputRequest::ApplyReviewBatch {
report,
worksheet,
batch,
output,
}
} else if first == "--apply-decision-patch" {
let report = args
.next()
Expand Down Expand Up @@ -507,6 +543,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = serde_json::to_vec_pretty(&batch)?;
write_private_output(&output, &content)?;
}
OutputRequest::ApplyReviewBatch {
report,
worksheet,
batch,
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 (batch, batch_identity): (StewardReviewBatch, _) =
read_private_json(&batch).map_err(|error| label_input("review batch", error))?;
if BTreeSet::from([report_identity, worksheet_identity, batch_identity]).len() != 3 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"review batch application inputs must be distinct files",
)
.into());
}
let patch = decision_patch_from_review_batch(&report, &worksheet, &batch)?;
let updated = apply_steward_decision_patch(&report, &worksheet, &patch)?;
write_private_output(&output, &serde_json::to_vec_pretty(&updated)?)?;
}
OutputRequest::ApplyDecisionPatch {
report,
worksheet,
Expand Down Expand Up @@ -777,6 +837,56 @@ mod tests {
);
}

#[test]
fn apply_review_batch_mode_requires_four_distinct_artifact_paths() {
let report = "/tmp/report.json";
let worksheet = "/tmp/worksheet.json";
let batch = "/tmp/batch.json";
let output = "/tmp/updated-worksheet.json";
assert_eq!(
parse_output_request(vec![
"--apply-review-batch",
report,
worksheet,
batch,
output,
]),
Ok(OutputRequest::ApplyReviewBatch {
report: report.to_owned(),
worksheet: worksheet.to_owned(),
batch: batch.to_owned(),
output: output.to_owned(),
})
);
assert!(parse_output_request(vec!["--apply-review-batch"]).is_err());
assert!(parse_output_request(vec!["--apply-review-batch", report]).is_err());
assert!(parse_output_request(vec!["--apply-review-batch", report, worksheet]).is_err());
assert!(
parse_output_request(vec!["--apply-review-batch", report, worksheet, batch]).is_err()
);
assert!(
parse_output_request(vec![
"--apply-review-batch",
report,
worksheet,
batch,
report,
])
.is_err()
);
assert!(
parse_output_request(vec![
"--apply-review-batch",
report,
worksheet,
batch,
output,
"extra",
])
.is_err()
);
}

#[test]
fn review_batch_mode_requires_distinct_paths_and_decimal_limit() {
let report = "/tmp/report.json";
Expand Down Expand Up @@ -1169,8 +1279,8 @@ mod tests {

assert!(validate_output_path("relative.json").is_err());
assert!(validate_output_path("/").is_err());
let missing_name =
validate_output_path(env::temp_dir().join("..").to_str().unwrap()).unwrap_err();
let missing_name = validate_output_path(env::temp_dir().join("..").to_str().unwrap())
.expect_err("an allowed parent still requires a file name");
assert_eq!(missing_name.kind(), io::ErrorKind::InvalidInput);
assert_eq!(missing_name.to_string(), "report output has no file name");
assert!(validate_output_path("/tmp/missing-directory/report.json").is_err());
Expand Down
29 changes: 24 additions & 5 deletions crates/conceptweave-zotero/tests/finalization_artifact_identity.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#![cfg(unix)]

use conceptweave_zotero::{
Disposition, GoldenSetApproval, ItemData, ZoteroItem, build_steward_review_worksheet,
classify_snapshot,
Disposition, GoldenSetApproval, ItemData, StewardDecisionPatch, StewardDecisionUpdate,
ZoteroItem, build_steward_review_worksheet, classify_snapshot,
};
use std::fs;
use std::os::unix::fs::PermissionsExt;
Expand Down Expand Up @@ -39,8 +39,8 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
snapshot_items: worksheet.snapshot_items.clone(),
};

// These structs intentionally accept additional owner-only fields. Without checking file
// identity, one JSON object can therefore be accepted as all three finalization inputs.
// The finalization inputs accept overlapping owner-only fields. Without checking file
// identity, one JSON object can therefore be accepted as all three inputs.
let mut combined = serde_json::to_value(&report).unwrap();
let object = combined.as_object_mut().unwrap();
object.insert(
Expand Down Expand Up @@ -70,10 +70,28 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
"conceptweave-zotero-finalize-alias-{}-output.json",
std::process::id()
));
let patch_path = temp.join(format!(
"conceptweave-zotero-finalize-alias-{}-patch.json",
std::process::id()
));
let _ = fs::remove_file(&input);
let _ = fs::remove_file(&output);
let _ = fs::remove_file(&patch_path);
fs::write(&input, serde_json::to_vec(&combined).unwrap()).unwrap();
fs::set_permissions(&input, fs::Permissions::from_mode(0o600)).unwrap();
let patch = StewardDecisionPatch {
library_version: worksheet.library_version,
rule_revision: worksheet.rule_revision.clone(),
snapshot_digest: worksheet.snapshot_digest.clone(),
proposal_digest: worksheet.proposal_digest.clone(),
decisions: vec![StewardDecisionUpdate {
item_key: "ITEM".into(),
item_version: 7,
reviewed_disposition: Disposition::OutOfScope,
}],
};
fs::write(&patch_path, serde_json::to_vec(&patch).unwrap()).unwrap();
fs::set_permissions(&patch_path, fs::Permissions::from_mode(0o600)).unwrap();

let report_path = input.to_str().unwrap().to_owned();
let worksheet_path = format!("{}/./{}", temp.display(), filename);
Expand Down Expand Up @@ -105,7 +123,7 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
"--apply-decision-patch",
&report_path,
&worksheet_path,
&approval_path,
patch_path.to_str().unwrap(),
output.to_str().unwrap(),
])
.status()
Expand All @@ -123,6 +141,7 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {

let _ = fs::remove_file(&input);
let _ = fs::remove_file(&output);
let _ = fs::remove_file(&patch_path);
assert!(
!status.success(),
"finalization must reject three path spellings that resolve to one input artifact"
Expand Down
35 changes: 35 additions & 0 deletions crates/conceptweave-zotero/tests/output_parent_canonicalization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#![cfg(unix)]

#[test]
fn sensitive_output_path_is_rebuilt_from_the_validated_canonical_parent() {
let source = include_str!("../src/main.rs");
let start = source
.find("fn validate_output_path(raw: &str) -> io::Result<PathBuf> {")
.expect("validate_output_path must remain owned by the Zotero CLI boundary");
let tail = &source[start..];
let end = tail
.find("\n}\n\n/// Creates a new sensitive report file")
.expect("validate_output_path function boundary must remain inspectable");
let function = &tail[..end];

assert!(
function.contains("let file_name = path.file_name()"),
"validated output must retain only the final filename after canonicalizing its parent"
);
assert!(
function.contains("let validated_path = resolved_parent.join(file_name);"),
"output writes must use a path rebuilt from the validated canonical parent"
);
assert!(
function.contains("fs::symlink_metadata(&validated_path)"),
"existence/symlink checks must inspect the same canonical path later opened for creation"
);
assert!(
function.contains("Ok(validated_path)"),
"callers must receive the canonical rebuilt output path rather than the raw symlink-bearing path"
);
assert!(
!function.contains("Ok(path)"),
"returning the raw path reintroduces an upper-parent symlink TOCTOU between validation and create_new"
);
}
Loading