Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b2a4263
test(zotero): require bounded steward review batches
seonghobae Sep 4, 2026
f668d4c
feat(zotero): build bounded steward review batches
seonghobae Sep 4, 2026
400c029
feat(zotero): export owner-only steward review batches
seonghobae Sep 4, 2026
b0ae218
test(zotero): prove review batch artifact safety
seonghobae Sep 4, 2026
2f7b608
test(zotero): minimize review batch context
seonghobae Sep 4, 2026
49d6b1d
docs(zotero): define bounded human review batches
seonghobae Sep 4, 2026
f975065
style(zotero): format steward review batch tests
seonghobae Sep 4, 2026
bdb8ac3
test(zotero): cover review batch failures
seonghobae Sep 4, 2026
2cd51f9
refactor(zotero): reuse validated batch membership
seonghobae Sep 4, 2026
b064e37
test(zotero): reject duplicated batch abstracts
seonghobae Sep 4, 2026
3169018
fix(zotero): validate restored review context once
seonghobae Sep 4, 2026
7e16038
docs(zotero): narrow review batch aggregate claim
seonghobae Sep 4, 2026
9703f45
test(zotero): assert canonical context rejection
seonghobae Sep 4, 2026
c8760a5
style(zotero): format review context validation
seonghobae Sep 4, 2026
11aff4e
docs(zotero): record live steward batch evidence
seonghobae Sep 4, 2026
89d03bf
Merge PR #32 coverage-contract successor without force
seonghobae Sep 4, 2026
8d6dece
fix(zotero): cover private artifact helpers
seonghobae Sep 4, 2026
f6086bb
test(zotero): cover private write failure
seonghobae Sep 4, 2026
03dafd3
Merge PR #32 parent-path repair without force
seonghobae Sep 4, 2026
dc9e191
Merge repaired write receipt evidence into zotero-steward-review-batch
seonghobae Sep 4, 2026
28f9171
Merge write receipt fixture ownership repair into PR 33
seonghobae Sep 4, 2026
cc5305f
Merge current receipt repair parent into zotero-steward-review-batch
seonghobae Sep 4, 2026
9315f44
Merge remote-tracking branch 'origin/autoresearch/zotero-review-decis…
seonghobae Sep 4, 2026
0d10207
Merge complete receipt binding coverage into PR 33
seonghobae Sep 4, 2026
6a9bf63
merge(zotero): adopt current decision-cli parent
seonghobae Sep 4, 2026
4feb387
merge(zotero): adopt current decision-cli parent and gap baseline
seonghobae Sep 5, 2026
3b1f910
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
64ced36
test(research): adopt captured-source review batch fixture
seonghobae Sep 5, 2026
cdc7f69
test(research): adopt captured-source review batch CLI fixture
seonghobae Sep 5, 2026
8eb884a
merge(research): inherit canonical local transport repairs into PR #33
seonghobae Sep 5, 2026
ecddb79
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
e6a9417
Merge verified private artifact boundary repair into PR 33
seonghobae Sep 5, 2026
1893278
merge(zotero): propagate validated approval ordering through PR 33
seonghobae Sep 5, 2026
d82b2f2
merge(research): inherit bounded metadata reads into PR #33
seonghobae Sep 6, 2026
2f3962e
merge: retain batch privacy checks with repaired source admission
seonghobae Sep 6, 2026
7373579
test(zotero): expose omitted batch content and pending scope
seonghobae Sep 6, 2026
ce15389
fix(zotero): preserve validated scope in review batches
seonghobae Sep 6, 2026
ad712bd
docs(zotero): distinguish batch exhaustion from source completion
seonghobae Sep 6, 2026
acbaf1d
test(zotero): preserve valid abstention context in stale review check
seonghobae Sep 6, 2026
e2f756b
docs(zotero): explain valid-context regression and next review gate
seonghobae Sep 6, 2026
93faf6a
docs(zotero): record batch scope and privacy verification
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ 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
delete a pathname that another process may have replaced.

Create a small deterministic view of the next pending records for human review:

```sh
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`.

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

**Automatic, evidence-bound ontology and semantic-layer engineering for governed enterprise meaning.**
Expand Down
125 changes: 122 additions & 3 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ pub enum AbstentionReason {
}

/// Evidence for a deterministic proposed disposition.
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ClassificationEvidence {
/// Metadata fields whose values matched.
pub fields: Vec<String>,
Expand All @@ -156,7 +156,7 @@ pub struct ClassificationEvidence {
}

/// A single top-level bibliographic classification proposal.
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ClassifiedItem {
/// Stable Zotero item key.
pub item_key: String,
Expand Down Expand Up @@ -1189,16 +1189,73 @@ pub struct StewardDecisionPatch {
pub decisions: Vec<StewardDecisionUpdate>,
}

/// Maximum number of sensitive records exposed in one local steward review batch.
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)]
pub struct StewardReviewBatchDecision {
/// Stable Zotero item key used to apply the completed decision.
pub item_key: String,
/// Exact item revision reviewed by the steward.
pub item_version: u64,
/// Zotero item type retained for local review context.
pub item_type: String,
/// Human-readable title retained in this owner-only artifact.
pub title: String,
/// Minimal abstract context retained only for abstained proposals.
pub review_abstract_note: Option<String>,
/// Collection keys observed with the item.
pub collection_keys: Vec<String>,
/// Typed tags observed with the item.
pub tags: Vec<ItemTag>,
/// Deterministic proposal shown for comparison, never as approval.
pub proposed_disposition: Disposition,
/// Deterministic abstention reason when the proposal requires review.
pub abstention_reason: Option<AbstentionReason>,
/// Deterministic evidence retained from the exact report snapshot.
pub evidence: ClassificationEvidence,
/// Blank slot for the human steward to fill with non-abstention truth.
pub reviewed_disposition: Option<Disposition>,
}

/// Deterministic owner-only view of the next pending steward decisions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StewardReviewBatch {
/// Zotero library revision shared with the report and worksheet.
pub library_version: u64,
/// Classifier revision whose proposals are being reviewed.
pub rule_revision: String,
/// Canonical digest of the complete raw snapshot.
pub snapshot_digest: String,
/// Opaque identity retained unchanged when completed decisions become a patch.
pub proposal_digest: String,
/// Unresolved source records, separate from blank bibliographic decision slots.
pub pending_source_count: usize,
/// Pending decisions before this non-reserving view was created.
pub remaining_count: usize,
/// First pending decisions in canonical item-key order.
pub decisions: Vec<StewardReviewBatchDecision>,
}

/// A classification report cannot safely produce a review worksheet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorksheetError {
/// Report identity or coverage is incomplete, duplicated, or inconsistent.
InvalidReport,
/// Requested review batch size is outside the supported local bound.
InvalidBatchLimit,
/// The canonical worksheet contains no blank decision.
NoPendingDecisions,
}

impl fmt::Display for WorksheetError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("classification report is invalid for steward review")
formatter.write_str(match self {
Self::InvalidReport => "classification report is invalid for steward review",
Self::InvalidBatchLimit => "review batch limit must be between 1 and 100",
Self::NoPendingDecisions => "steward worksheet has no pending decisions",
})
}
}

Expand All @@ -1220,6 +1277,17 @@ pub fn build_steward_review_worksheet(
for item in &report.classified_items {
if (item.proposed_disposition == Disposition::NeedsStewardReview)
!= item.abstention_reason.is_some()
|| (item.proposed_disposition != Disposition::NeedsStewardReview
&& item.review_abstract_note.is_some())
|| item
.review_abstract_note
.as_ref()
.is_some_and(|review_abstract| {
item.evidence
.field_values
.values()
.any(|value| value == review_abstract)
})
{
return Err(WorksheetError::InvalidReport);
}
Expand Down Expand Up @@ -1279,6 +1347,57 @@ pub fn assess_steward_review_progress(
})
}

/// Builds a deterministic, non-reserving view of the next pending review decisions.
pub fn build_steward_review_batch(
report: &ClassificationReport,
worksheet: &StewardReviewWorksheet,
limit: usize,
) -> Result<StewardReviewBatch, WorksheetError> {
if !(1..=MAX_REVIEW_BATCH_ITEMS).contains(&limit) {
return Err(WorksheetError::InvalidBatchLimit);
}
let progress = assess_steward_review_progress(report, worksheet)?;
if progress.remaining_count == 0 {
return Err(WorksheetError::NoPendingDecisions);
}
let classified_by_key: BTreeMap<_, _> = report
.classified_items
.iter()
.map(|item| (item.item_key.as_str(), item))
.collect();
let decisions = worksheet
.decisions
.iter()
.filter(|decision| decision.reviewed_disposition.is_none())
.take(limit)
.map(|decision| {
let item = classified_by_key[decision.item_key.as_str()];
StewardReviewBatchDecision {
item_key: item.item_key.clone(),
item_version: item.item_version,
item_type: item.item_type.clone(),
title: item.title.clone(),
review_abstract_note: item.review_abstract_note.clone(),
collection_keys: item.collection_keys.clone(),
tags: item.tags.clone(),
proposed_disposition: item.proposed_disposition,
abstention_reason: item.abstention_reason,
evidence: item.evidence.clone(),
reviewed_disposition: None,
}
})
.collect();
Ok(StewardReviewBatch {
library_version: report.library_version,
rule_revision: report.rule_revision.clone(),
snapshot_digest: report.snapshot_digest.clone(),
proposal_digest: progress.proposal_digest,
pending_source_count: progress.pending_source_count,
remaining_count: progress.remaining_count,
decisions,
})
}

/// Applies one snapshot-bound decision patch without overwriting conflicting review work.
pub fn apply_steward_decision_patch(
report: &ClassificationReport,
Expand Down
120 changes: 116 additions & 4 deletions crates/conceptweave-zotero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]

use conceptweave_zotero::{
ClassificationReport, GoldenSetApproval, StewardDecisionPatch, StewardReviewWorksheet,
apply_steward_decision_patch, assess_steward_review_progress, build_steward_review_worksheet,
read_local_snapshot, reviewed_golden_set_from_worksheet,
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,
};
use serde::de::DeserializeOwned;
use std::collections::BTreeSet;
Expand All @@ -13,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 | --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-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 @@ -28,6 +29,12 @@ enum OutputRequest {
worksheet: String,
output: String,
},
ReviewBatch {
report: String,
worksheet: String,
limit: usize,
output: String,
},
ApplyDecisionPatch {
report: String,
worksheet: String,
Expand Down Expand Up @@ -79,6 +86,37 @@ where
worksheet,
output,
}
} else if first == "--review-batch" {
let report = args
.next()
.ok_or("--review-batch requires report, worksheet, limit, and output")?;
let worksheet = args
.next()
.ok_or("--review-batch requires report, worksheet, limit, and output")?;
let limit = args
.next()
.ok_or("--review-batch requires report, worksheet, limit, and output")?;
let output = args
.next()
.ok_or("--review-batch requires report, worksheet, limit, and output")?;
if BTreeSet::from([report.as_str(), worksheet.as_str(), output.as_str()]).len() != 3 {
return Err("review batch artifact paths must differ");
}
if limit.is_empty() || !limit.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("review batch limit must be an unsigned decimal integer");
}
let limit = limit
.parse::<usize>()
.map_err(|_| "review batch limit is out of range")?;
if !(1..=MAX_REVIEW_BATCH_ITEMS).contains(&limit) {
return Err("review batch limit must be between 1 and 100");
}
OutputRequest::ReviewBatch {
report,
worksheet,
limit,
output,
}
} else if first == "--apply-decision-patch" {
let report = args
.next()
Expand Down Expand Up @@ -447,6 +485,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let progress = assess_steward_review_progress(&report, &worksheet)?;
write_private_output(&output, &serde_json::to_vec_pretty(&progress)?)?;
}
OutputRequest::ReviewBatch {
report,
worksheet,
limit,
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))?;
if report_identity == worksheet_identity {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"review batch inputs must be distinct files",
)
.into());
}
let batch = build_steward_review_batch(&report, &worksheet, limit)?;
let content = serde_json::to_vec_pretty(&batch)?;
write_private_output(&output, &content)?;
}
OutputRequest::ApplyDecisionPatch {
report,
worksheet,
Expand Down Expand Up @@ -717,6 +777,58 @@ mod tests {
);
}

#[test]
fn review_batch_mode_requires_distinct_paths_and_decimal_limit() {
let report = "/tmp/report.json";
let worksheet = "/tmp/worksheet.json";
let output = "/tmp/batch.json";
assert_eq!(
parse_output_request(vec!["--review-batch", report, worksheet, "25", output]),
Ok(OutputRequest::ReviewBatch {
report: report.to_owned(),
worksheet: worksheet.to_owned(),
limit: 25,
output: output.to_owned(),
})
);
for limit in [
"",
"0",
"101",
" 1",
"+1",
"-1",
"one",
"9999999999999999999999999999999999999999",
] {
assert!(
parse_output_request(vec!["--review-batch", report, worksheet, limit, output])
.is_err()
);
}
assert!(parse_output_request(vec!["--review-batch"]).is_err());
assert!(parse_output_request(vec!["--review-batch", report]).is_err());
assert!(parse_output_request(vec!["--review-batch", report, worksheet]).is_err());
assert!(parse_output_request(vec!["--review-batch", report, worksheet, "25"]).is_err());
assert!(
parse_output_request(vec!["--review-batch", report, report, "25", output]).is_err()
);
assert!(
parse_output_request(vec!["--review-batch", report, worksheet, "25", report]).is_err()
);
assert!(
parse_output_request(vec![
"--review-batch",
report,
worksheet,
"25",
output,
"extra",
])
.is_err()
);
}

#[cfg(unix)]
#[test]
fn private_json_input_is_owner_only_regular_bounded_and_valid() {
Expand Down
14 changes: 14 additions & 0 deletions crates/conceptweave-zotero/tests/finalization_artifact_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
])
.status()
.unwrap();
let batch_status = Command::new(env!("CARGO_BIN_EXE_conceptweave-zotero"))
.args([
"--review-batch",
&report_path,
&worksheet_path,
"1",
output.to_str().unwrap(),
])
.status()
.unwrap();

let _ = fs::remove_file(&input);
let _ = fs::remove_file(&output);
Expand All @@ -125,4 +135,8 @@ fn artifact_commands_reject_distinct_path_spellings_for_one_input_file() {
!patch_status.success(),
"decision patching must reject path spellings that resolve to one input artifact"
);
assert!(
!batch_status.success(),
"review batching must reject path spellings that resolve to one input artifact"
);
}
Loading