Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4324218
experiment: add reversible duplicate review manifest
seonghobae Sep 4, 2026
44114d1
Merge branch 'autoresearch/zotero-reclassification-sep04' into autore…
seonghobae Sep 4, 2026
667cbe6
test(zotero): lock duplicate review integrity findings
seonghobae Sep 4, 2026
f7ef650
fix(zotero): enforce duplicate review integrity
seonghobae Sep 4, 2026
43c8a52
test(zotero): cover transitive duplicate components
seonghobae Sep 4, 2026
e4a9cd0
fix(zotero): resolve transitive duplicate components
seonghobae Sep 4, 2026
7fb7bfa
test(zotero): cover missing duplicate revision
seonghobae Sep 4, 2026
89a3ffe
test(zotero): bind duplicate review coordinates
seonghobae Sep 4, 2026
c32d3f0
fix(zotero): preserve component review evidence
seonghobae Sep 4, 2026
6df2967
docs(zotero): define connected duplicate evidence
seonghobae Sep 4, 2026
e77a2d2
test(zotero): reject detached duplicate candidates
seonghobae Sep 4, 2026
eba815b
fix(zotero): bind duplicate candidate membership
seonghobae Sep 4, 2026
5bf58aa
fix(zotero): serialize reviewed duplicate identities
seonghobae Sep 4, 2026
2d503ab
docs(zotero): bind reviewed duplicate membership
seonghobae Sep 4, 2026
0fa629e
chore(research): restack duplicate-review manifest
seonghobae Sep 4, 2026
42103a8
merge(research): adopt current audit parent
seonghobae Sep 4, 2026
5ce1a18
merge(research): adopt current audit parent and gap baseline
seonghobae Sep 5, 2026
3bed887
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
ee2c494
merge(research): inherit metadata transport owner repair into PR #12
seonghobae Sep 5, 2026
a4a7c2d
merge(research): inherit bounded metadata reads into PR #12
seonghobae Sep 6, 2026
b758991
merge: preserve source admission and audit in duplicate owner
seonghobae Sep 6, 2026
4656d6b
test: expose duplicate review source scope bypass
seonghobae Sep 6, 2026
a27363a
fix: bind duplicate review to complete validated source scope
seonghobae Sep 6, 2026
fc0465e
fix: retain duplicate snapshot mismatch error precedence
seonghobae Sep 6, 2026
0c825d9
test: preserve independent authority for rewritten duplicate scope
seonghobae Sep 6, 2026
5fff9d0
test: remove redundant borrowed report reference
seonghobae Sep 6, 2026
3d2c252
docs: bind duplicate source scope to reviewed evidence
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to ConceptWeave are documented here.

### Fixed

- Duplicate review rejects incomplete source inventories and changed supporting evidence before approval, while retaining reversible identity mappings.
- Research evaluation rejects incomplete source inventories and invalidates prior approvals when retained source metadata changes.
- Research reports retain standalone files and notes that previously disappeared from the classification view, and flag sources whose parent relationships remain unresolved.
- Zotero research intake rejects a read whose records claim revisions newer than the library being observed, without dropping papers or changing their recorded revisions.
Expand Down
265 changes: 262 additions & 3 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,122 @@ pub struct ClassifiedItem {
}

/// A duplicate candidate group; no item is merged or deleted.
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct DuplicateCandidate {
/// Identity kind used for the candidate group.
pub identity_kind: &'static str,
pub identity_kind: String,
/// Normalized identity value.
pub normalized_identity: String,
/// Zotero item keys sharing the identity.
pub item_keys: Vec<String>,
}

/// One steward decision selecting the canonical identity for a duplicate cluster.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct DuplicateMergeDecision {
/// Candidate identity kind from the classification report.
pub identity_kind: String,
/// Candidate normalized identity from the classification report.
pub normalized_identity: String,
/// Existing Zotero item retained as the canonical reference.
pub retained_item_key: String,
}

/// Snapshot-bound duplicate decisions verified by the caller's governance boundary.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ReviewedDuplicateMergeSet {
/// Opaque steward review receipt.
pub review_id: String,
/// Opaque governance authority receipt; contains no person identity.
pub authority_receipt: String,
Comment on lines +199 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the duplicate-review concepts to the DDD documentation

This introduces new public domain concepts—including the reviewed duplicate set, authority receipt, canonical-key operation, and the Research Intake-to-Governance verification handoff—but neither docs/UBIQUITOUS_LANGUAGE.md nor docs/CONTEXT_MAP.md defines that language or relationship. Update those DDD artifacts so the newly documented workflow does not diverge from the repository's canonical domain model.

AGENTS.md reference: AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

/// Exact Zotero library revision reviewed by the steward.
pub library_version: u64,
/// Exact classifier rule revision reviewed by the steward.
pub rule_revision: String,
/// Exact raw-snapshot digest reviewed by the steward.
pub snapshot_digest: String,
/// Required v2 identity of proposals, retained metadata and pending sources.
pub proposal_digest: String,
/// Exact item-key/item-version coordinates reviewed by the steward.
pub snapshot_items: Vec<SnapshotItemRevision>,
/// Exact duplicate membership reviewed by the steward.
pub duplicate_candidates: Vec<DuplicateCandidate>,
/// Exactly one decision for every duplicate candidate.
pub decisions: Vec<DuplicateMergeDecision>,
}

/// One reversible canonical-key mapping; Zotero source records remain unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DuplicateMergeOperation {
/// Candidate identity kind.
pub identity_kind: String,
/// Candidate normalized identity retained only in the local manifest.
pub normalized_identity: String,
/// Steward-selected canonical Zotero item key.
pub retained_item_key: String,
/// Exact source revisions participating in the decision.
pub source_items: Vec<SnapshotItemRevision>,
/// Mapping before the reviewed canonicalization; every item maps to itself.
pub before_canonical_keys: BTreeMap<String, String>,
/// Reviewed mapping after canonicalization; every item maps to the retained key.
pub after_canonical_keys: BTreeMap<String, String>,
/// Exact inverse plan restoring the pre-review mapping.
pub rollback_canonical_keys: BTreeMap<String, String>,
}

/// Aggregate of reviewed, reversible duplicate identity operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DuplicateMergeReviewManifest {
/// Opaque steward review receipt.
pub review_id: String,
/// Opaque governance authority receipt.
pub authority_receipt: String,
/// Exact Zotero library revision reviewed by the steward.
pub library_version: u64,
/// Exact classifier rule revision reviewed by the steward.
pub rule_revision: String,
/// Exact raw-snapshot digest shared with the reviewed decisions.
pub snapshot_digest: String,
/// Verified identity of the proposals and complete retained source scope.
pub proposal_digest: String,
/// Deterministically ordered canonical-key operations.
pub operations: Vec<DuplicateMergeOperation>,
/// Classification never deletes or mutates Zotero source records.
pub source_records_preserved: bool,
}

/// A fail-closed duplicate review contract violation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DuplicateReviewError {
/// Required review metadata or a decision is missing.
InvalidReview,
/// The review belongs to another raw snapshot.
SnapshotMismatch,
/// A decision does not identify a report candidate.
UnknownCandidate,
/// More than one decision targets the same candidate.
DuplicateDecision,
/// The retained key is not a member of the candidate cluster.
InvalidRetainedItem,
/// The caller's governance boundary rejected the complete review set.
UnverifiedApproval,
}

impl fmt::Display for DuplicateReviewError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidReview => "duplicate review metadata or decisions are invalid",
Self::SnapshotMismatch => "duplicate review does not match the report snapshot",
Self::UnknownCandidate => "duplicate review contains an unknown candidate",
Self::DuplicateDecision => "duplicate review repeats a candidate decision",
Self::InvalidRetainedItem => "retained item is absent from its duplicate candidate",
Self::UnverifiedApproval => "duplicate review approval is unverified",
})
}
}

impl std::error::Error for DuplicateReviewError {}

/// Complete local classification report for one immutable library version.
#[derive(Debug, Serialize)]
pub struct ClassificationReport {
Expand Down Expand Up @@ -584,6 +690,159 @@ where
})
}

/// Builds a local-only reversible canonical-key manifest from steward-reviewed decisions.
///
/// Validates source scope, receipt bindings and all operations before contacting
/// governance. The verifier must authenticate the complete independently issued
/// review, including candidate membership and the v2 proposal digest. Legacy
/// receipts require reapproval; digest recomputation alone grants no authority.
pub fn build_duplicate_merge_review_manifest<F>(
report: &ClassificationReport,
reviewed: &ReviewedDuplicateMergeSet,
verify_review: F,
) -> Result<DuplicateMergeReviewManifest, DuplicateReviewError>
where
F: FnOnce(&ReviewedDuplicateMergeSet) -> bool,
{
if reviewed.review_id.trim().is_empty()
|| reviewed.authority_receipt.trim().is_empty()
|| reviewed.snapshot_digest.trim().is_empty()
|| reviewed.proposal_digest.trim().is_empty()
|| reviewed.rule_revision.trim().is_empty()
|| reviewed.decisions.len() != report.duplicate_candidates.len()
{
return Err(DuplicateReviewError::InvalidReview);
}
if reviewed.snapshot_digest != report.snapshot_digest
|| reviewed.proposal_digest != classification_proposal_digest(report)
|| reviewed.library_version != report.library_version
|| reviewed.rule_revision != report.rule_revision
|| reviewed.snapshot_items != report.snapshot_items
|| reviewed.duplicate_candidates != report.duplicate_candidates
{
return Err(DuplicateReviewError::SnapshotMismatch);
}
validate_classification_report(report).map_err(|_| DuplicateReviewError::InvalidReview)?;
let item_revisions = report
.snapshot_items
.iter()
.map(|item| (item.item_key.as_str(), item.item_version))
.collect::<BTreeMap<_, _>>();
Comment on lines +726 to +730

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate snapshot-key uniqueness before recording revisions

When the public classify_snapshot entry point receives repeated Zotero keys, it can produce a candidate containing the same key more than once, and this collect silently keeps only the last version. A subsequently approved manifest then emits that surviving version for every occurrence while claiming to preserve the exact source revisions, corrupting the review evidence instead of failing closed. Validate that snapshot_items contains unique, nonblank keys before building this map, as the golden-set path already does.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

let candidates = report
.duplicate_candidates
.iter()
.map(|candidate| {
(
(
candidate.identity_kind.as_str(),
candidate.normalized_identity.as_str(),
),
candidate,
)
})
.collect::<BTreeMap<_, _>>();
let mut seen_candidates = BTreeSet::new();
let mut operations = Vec::with_capacity(reviewed.decisions.len());

for decision in &reviewed.decisions {
let candidate_key = (
decision.identity_kind.as_str(),
decision.normalized_identity.as_str(),
);
if !seen_candidates.insert(candidate_key) {
return Err(DuplicateReviewError::DuplicateDecision);
}
let candidate = candidates
.get(&candidate_key)
.ok_or(DuplicateReviewError::UnknownCandidate)?;
// ponytail: quadratic component expansion is enough for a steward-sized review;
// replace with union-find only if measured duplicate sets become large.
let mut component_keys = candidate.item_keys.iter().collect::<BTreeSet<_>>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind candidate membership to the reviewed snapshot

When a reviewed {A,B} candidate shares a snapshot with an unrelated item C, replacing the public candidate's B key with C after review passes every check because C exists in item_revisions; this line then emits C -> A under the unchanged verified receipt while silently omitting B. Although the new detached-key test rejects missing, substitution with another valid snapshot key still bypasses it because candidate membership is absent from ReviewedDuplicateMergeSet and is never rederived from the reviewed digest. Bind the exact candidate/component membership into the verified review or recompute and validate it from immutable snapshot evidence before materializing operations.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

loop {
let previous_len = component_keys.len();
for related in &report.duplicate_candidates {
if related
.item_keys
.iter()
.any(|item_key| component_keys.contains(item_key))
{
component_keys.extend(&related.item_keys);
}
}
if component_keys.len() == previous_len {
break;
}
}
if !component_keys.contains(&decision.retained_item_key) {
return Err(DuplicateReviewError::InvalidRetainedItem);
}
if reviewed.decisions.iter().any(|related_decision| {
candidates
.get(&(
related_decision.identity_kind.as_str(),
related_decision.normalized_identity.as_str(),
))
.is_some_and(|related_candidate| {
related_candidate
.item_keys
.iter()
.any(|item_key| component_keys.contains(item_key))
&& related_decision.retained_item_key != decision.retained_item_key
})
}) {
return Err(DuplicateReviewError::InvalidReview);
}

let source_items = component_keys
.iter()
.map(|item_key| {
item_revisions
.get(item_key.as_str())
.map(|item_version| SnapshotItemRevision {
item_key: (*item_key).clone(),
item_version: *item_version,
Comment on lines +799 to +803

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind emitted item revisions to the reviewed snapshot

The new key-only validation still permits a caller to alter a public ClassificationReport.snapshot_items entry's version while retaining the original reviewed digest; every check passes and this line emits the altered version under the authority receipt for the original snapshot. Since the tests already treat mutated reports as untrusted inputs, bind the reviewed set to the complete item-key/version coordinates, as the golden-set contract does, before materializing these source revisions.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

})
.ok_or(DuplicateReviewError::InvalidReview)
})
.collect::<Result<Vec<_>, _>>()?;
let before_canonical_keys = component_keys
.iter()
.map(|item_key| ((*item_key).clone(), (*item_key).clone()))
.collect::<BTreeMap<_, _>>();
let after_canonical_keys = component_keys
.iter()
.map(|item_key| ((*item_key).clone(), decision.retained_item_key.clone()))
.collect::<BTreeMap<_, _>>();
operations.push(DuplicateMergeOperation {
identity_kind: decision.identity_kind.clone(),
normalized_identity: decision.normalized_identity.clone(),
retained_item_key: decision.retained_item_key.clone(),
source_items,
rollback_canonical_keys: before_canonical_keys.clone(),
before_canonical_keys,
after_canonical_keys,
});
}

if !verify_review(reviewed) {
return Err(DuplicateReviewError::UnverifiedApproval);
}
operations.sort_by(|left, right| {
(&left.identity_kind, &left.normalized_identity)
.cmp(&(&right.identity_kind, &right.normalized_identity))
});
Ok(DuplicateMergeReviewManifest {
review_id: reviewed.review_id.clone(),
authority_receipt: reviewed.authority_receipt.clone(),
library_version: reviewed.library_version,
rule_revision: reviewed.rule_revision.clone(),
snapshot_digest: reviewed.snapshot_digest.clone(),
proposal_digest: reviewed.proposal_digest.clone(),
operations,
source_records_preserved: true,
})
}

/// Failure raised when a bounded, immutable Local API read cannot be proven.
#[derive(Debug)]
pub enum ReadError {
Expand Down Expand Up @@ -1211,7 +1470,7 @@ fn duplicate_candidates(items: &[&ZoteroItem]) -> Vec<DuplicateCandidate> {
.into_iter()
.filter_map(|((identity_kind, normalized_identity), item_keys)| {
(item_keys.len() > 1).then_some(DuplicateCandidate {
identity_kind,
identity_kind: identity_kind.into(),
normalized_identity,
item_keys,
})
Expand Down
Loading