Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
05c1cc3
test(zotero): bind incremental steward decisions
seonghobae Sep 4, 2026
96eb365
feat(zotero): apply snapshot-bound decision patches
seonghobae Sep 4, 2026
e8f6e84
docs(zotero): define incremental review patches
seonghobae Sep 4, 2026
42236d8
test(zotero): cover decision patch rejection paths
seonghobae Sep 4, 2026
b079524
test(zotero): build independent invalid report
seonghobae Sep 4, 2026
15d44f3
test(zotero): reject preexisting review abstention
seonghobae Sep 4, 2026
963841b
fix(zotero): reject invalid review state before patching
seonghobae Sep 4, 2026
4f45404
docs(zotero): state patch abstention invariant
seonghobae Sep 4, 2026
9a8a16a
Merge PR #30 coverage-contract successor without force
seonghobae Sep 4, 2026
9ba7d65
Merge PR #30 parent-path contract successor without force
seonghobae Sep 4, 2026
93ba30f
Merge PR #30 parent-path repair without force
seonghobae Sep 4, 2026
f5a4618
Merge repaired write receipt evidence into zotero-review-decision-patch
seonghobae Sep 4, 2026
1ca8a79
test: preserve owned rule revision fixture
seonghobae Sep 4, 2026
5e8bd6b
Merge current receipt repair parent into zotero-review-decision-patch
seonghobae Sep 4, 2026
0cf2bd1
Merge remote-tracking branch 'origin/autoresearch/zotero-review-progr…
seonghobae Sep 4, 2026
58da926
test: preserve complete receipt binding coverage
seonghobae Sep 4, 2026
6109481
merge(zotero): adopt current review-progress parent
seonghobae Sep 4, 2026
e5a1e52
merge(zotero): adopt current review-progress parent and gap baseline
seonghobae Sep 5, 2026
9c142d4
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
65b6472
test(research): adopt captured-source decision patch fixture
seonghobae Sep 5, 2026
7ec3a7e
merge(research): inherit canonical local transport repairs into PR #31
seonghobae Sep 5, 2026
d8ba81c
merge(research): inherit deterministic transport framing regression i…
seonghobae Sep 5, 2026
1c67d1e
Merge verified private artifact boundary repair into PR 31
seonghobae Sep 5, 2026
0dad7ca
merge(zotero): propagate validated approval ordering through PR 31
seonghobae Sep 5, 2026
61072a7
merge(research): inherit bounded metadata reads into PR #31
seonghobae Sep 6, 2026
ef0ce43
merge: inherit content-bound progress in decision patches
seonghobae Sep 6, 2026
7208d80
test(zotero): expose stale decision patch content admission
seonghobae Sep 6, 2026
89bb941
fix(zotero): require reviewed content identity in decision patches
seonghobae Sep 6, 2026
fde6a5e
docs(zotero): trace independent decision patch content binding
seonghobae Sep 6, 2026
eaf248a
docs(zotero): record decision patch content 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
83 changes: 83 additions & 0 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,32 @@ pub struct StewardReviewProgress {
pub complete: bool,
}

/// One snapshot-bound steward decision supplied without rewriting a worksheet by hand.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct StewardDecisionUpdate {
/// Stable Zotero item key of the reviewed bibliographic item.
pub item_key: String,
/// Exact item revision reviewed by the steward.
pub item_version: u64,
/// Human-reviewed truth label; abstention is not valid truth.
pub reviewed_disposition: Disposition,
}

/// A bounded set of local steward decisions for one immutable classification snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct StewardDecisionPatch {
/// Zotero library revision shared with the report and worksheet.
pub library_version: u64,
/// Classifier revision whose proposals were reviewed.
pub rule_revision: String,
/// Canonical digest of the complete raw snapshot.
pub snapshot_digest: String,
/// Required opaque identity of the proposals and retained metadata reviewed for this patch.
pub proposal_digest: String,
/// Unique item-revision decisions to apply atomically.
pub decisions: Vec<StewardDecisionUpdate>,
}

/// A classification report cannot safely produce a review worksheet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorksheetError {
Expand Down Expand Up @@ -1253,6 +1279,63 @@ pub fn assess_steward_review_progress(
})
}

/// Applies one snapshot-bound decision patch without overwriting conflicting review work.
pub fn apply_steward_decision_patch(
report: &ClassificationReport,
worksheet: &StewardReviewWorksheet,
patch: &StewardDecisionPatch,
) -> Result<StewardReviewWorksheet, WorksheetError> {
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))
|| patch.library_version != expected.library_version
|| patch.rule_revision != expected.rule_revision
|| patch.snapshot_digest != expected.snapshot_digest
|| patch.proposal_digest != expected.proposal_digest
|| patch.decisions.is_empty()
{
return Err(WorksheetError::InvalidReport);
}

let mut updates = BTreeMap::new();
for decision in &patch.decisions {
if decision.item_key.trim().is_empty()
|| decision.reviewed_disposition == Disposition::NeedsStewardReview
|| updates
.insert(
decision.item_key.as_str(),
(decision.item_version, decision.reviewed_disposition),
)
.is_some()
{
return Err(WorksheetError::InvalidReport);
}
}

let mut updated = worksheet.clone();
for decision in &mut updated.decisions {
let Some((item_version, reviewed_disposition)) = updates.remove(decision.item_key.as_str())
else {
continue;
};
if item_version != decision.item_version
|| decision
.reviewed_disposition
.is_some_and(|existing| existing != reviewed_disposition)
{
return Err(WorksheetError::InvalidReport);
}
decision.reviewed_disposition = Some(reviewed_disposition);
}
if !updates.is_empty() {
return Err(WorksheetError::InvalidReport);
}
Ok(updated)
}

fn validate_steward_review_worksheet_against(
expected: &StewardReviewWorksheet,
worksheet: &StewardReviewWorksheet,
Expand Down
193 changes: 193 additions & 0 deletions crates/conceptweave-zotero/tests/steward_decision_patch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
use conceptweave_zotero::{
Disposition, ItemData, StewardDecisionPatch, StewardDecisionUpdate, WorksheetError, ZoteroItem,
apply_steward_decision_patch, build_steward_review_worksheet, classify_snapshot,
};

fn item(key: &str, title: &str) -> ZoteroItem {
ZoteroItem {
source_record: None,
key: key.into(),
version: 7,
data: ItemData {
item_type: "book".into(),
title: title.into(),
abstract_note: String::new(),
doi: String::new(),
parent_item: String::new(),
collections: vec![],
tags: vec![],
},
}
}

fn report() -> conceptweave_zotero::ClassificationReport {
classify_snapshot(
"9.0.6".into(),
None,
42,
vec![item("A", "ontology learning"), item("B", "unmatched")],
)
}

fn patch(item_key: &str, item_version: u64, disposition: Disposition) -> StewardDecisionPatch {
let report = report();
StewardDecisionPatch {
proposal_digest: conceptweave_zotero::classification_proposal_digest(&report),
library_version: report.library_version,
rule_revision: report.rule_revision,
snapshot_digest: report.snapshot_digest,
decisions: vec![StewardDecisionUpdate {
item_key: item_key.into(),
item_version,
reviewed_disposition: disposition,
}],
}
}

#[test]
fn decision_patch_is_snapshot_bound_idempotent_and_non_overwriting() {
let report = report();
let worksheet = build_steward_review_worksheet(&report).unwrap();
let update = patch("A", 7, Disposition::AlignmentVersioning);

let updated = apply_steward_decision_patch(&report, &worksheet, &update).unwrap();
assert_eq!(
updated.decisions[0].reviewed_disposition,
Some(Disposition::AlignmentVersioning)
);
assert_eq!(
apply_steward_decision_patch(&report, &updated, &update).unwrap(),
updated
);

let conflicting = patch("A", 7, Disposition::OutOfScope);
assert_eq!(
apply_steward_decision_patch(&report, &updated, &conflicting),
Err(WorksheetError::InvalidReport)
);
}

#[test]
fn stale_patch_cannot_use_a_fresh_worksheet_for_changed_content() {
let update = patch("A", 7, Disposition::AlignmentVersioning);
let mut report = report();
report.classified_items[0]
.title
.push_str(" changed evidence");
let worksheet = build_steward_review_worksheet(&report).unwrap();
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &update),
Err(WorksheetError::InvalidReport)
);
}

#[test]
fn patch_deserialization_requires_content_binding() {
let mut value = serde_json::to_value(patch("A", 7, Disposition::AlignmentVersioning)).unwrap();
value.as_object_mut().unwrap().remove("proposal_digest");
assert!(serde_json::from_value::<StewardDecisionPatch>(value).is_err());
}

#[test]
fn late_invalid_update_preserves_the_original_worksheet() {
let report = report();
let worksheet = build_steward_review_worksheet(&report).unwrap();
let before = worksheet.clone();
let mut update = patch("A", 7, Disposition::Generation);
update.decisions.push(StewardDecisionUpdate {
item_key: "UNKNOWN".into(),
item_version: 7,
reviewed_disposition: Disposition::OutOfScope,
});
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &update),
Err(WorksheetError::InvalidReport)
);
assert_eq!(worksheet, before);
}

#[test]
fn decision_patch_rejects_invalid_identity_and_truth() {
let report = report();
let worksheet = build_steward_review_worksheet(&report).unwrap();

let mut invalid_report = crate::report();
invalid_report.rule_revision.clear();
assert_eq!(
apply_steward_decision_patch(
&invalid_report,
&worksheet,
&patch("A", 7, Disposition::AlignmentVersioning)
),
Err(WorksheetError::InvalidReport)
);

let mut invalid_worksheet = worksheet.clone();
invalid_worksheet.decisions[0].item_version += 1;
assert_eq!(
apply_steward_decision_patch(
&report,
&invalid_worksheet,
&patch("A", 7, Disposition::AlignmentVersioning)
),
Err(WorksheetError::InvalidReport)
);

let mut abstaining_worksheet = worksheet.clone();
abstaining_worksheet.decisions[1].reviewed_disposition = Some(Disposition::NeedsStewardReview);
assert_eq!(
apply_steward_decision_patch(
&report,
&abstaining_worksheet,
&patch("A", 7, Disposition::AlignmentVersioning)
),
Err(WorksheetError::InvalidReport)
);

let mut invalid = patch("A", 7, Disposition::AlignmentVersioning);
invalid.library_version += 1;
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &invalid),
Err(WorksheetError::InvalidReport)
);

let mut invalid = patch("A", 7, Disposition::AlignmentVersioning);
invalid.rule_revision.clear();
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &invalid),
Err(WorksheetError::InvalidReport)
);

let mut invalid = patch("A", 7, Disposition::AlignmentVersioning);
invalid.snapshot_digest.clear();
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &invalid),
Err(WorksheetError::InvalidReport)
);

for invalid in [
patch(" ", 7, Disposition::OutOfScope),
patch("UNKNOWN", 7, Disposition::OutOfScope),
patch("A", 8, Disposition::OutOfScope),
patch("A", 7, Disposition::NeedsStewardReview),
] {
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &invalid),
Err(WorksheetError::InvalidReport)
);
}

let mut duplicate = patch("A", 7, Disposition::AlignmentVersioning);
duplicate.decisions.push(duplicate.decisions[0].clone());
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &duplicate),
Err(WorksheetError::InvalidReport)
);

let mut empty = patch("A", 7, Disposition::AlignmentVersioning);
empty.decisions.clear();
assert_eq!(
apply_steward_decision_patch(&report, &worksheet, &empty),
Err(WorksheetError::InvalidReport)
);
}
2 changes: 2 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ 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 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.

Operators must be able to accumulate small steward-reviewed decision sets without hand-merging the complete worksheet. Each patch binds the original library version, classifier revision, snapshot and proposal/retained-content digests, item key and item revision. Regenerating a worksheet after content changes cannot make an older patch valid. Missing content binding requires a new review-bound patch, never automatic backfill. Empty, duplicate, unknown, stale or abstention decisions fail atomically. Identical replay is idempotent; conflicting decisions cannot overwrite review work. Applying a patch does not confer independent approval, full-text review provenance or publication authority.

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.

A complete metadata-review result additionally requires exactly one non-abstention steward label for every top-level bibliographic item and no unresolved source records. Standalone sources, orphan trees and disconnected cycles must be resolved before completion; clearing their reported list cannot bypass inventory validation. A sampled golden set remains valid for quality measurement but cannot prove completion. Neither result proves full-text approval or an applied Zotero reclassification.
Expand Down
2 changes: 2 additions & 0 deletions docs/TRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ 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.

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.

The report is local JSON and contains proposals rather than governance decisions. On supported Unix platforms, CLI output is restricted to a new owner-readable/writable (`0600`) direct child of canonical `/tmp` or the operating system temporary directory; exact permissions are restored after umask application, and other platforms fail closed. Relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Reviewed collection/tag changes can produce a pure local plan whose default mode is dry-run. The plan requires exact report and item preconditions, complete before/after/rollback arrays, externally verified authority, and preserved Zotero tag types; its fields are externally read-only after validation. Zotero 9 execute mode fails closed. Every receipt copies the plan's review, authority, server, Zotero version, library, rule, snapshot and proposal coordinates; dry-run reports every operation as not attempted and makes no Local API call. Execute mode preflights every item before the first write, advances the library precondition only from a directly verified write response, stops on the first adapter or response failure, and re-reads that item through the same boundary as observation only. Failed writes remain indeterminate regardless of observed metadata; no inverse is issued for them. Prior directly verified operations retain their inverse coordinates. The API key remains adapter-owned and absent from serializable structures.
Expand Down
Loading