diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad11c4..4454ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to ConceptWeave are documented here. - A complete-review evaluator that rejects partial steward labels as full reclassification evidence. - A snapshot-bound steward worksheet with one blank decision per bibliographic item and no duplicated bibliographic text. - 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. ### Security diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 4059586..a11258e 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -1248,6 +1248,76 @@ pub struct GoldenSetApproval { pub snapshot_items: Vec, } +/// Converts a fully decided local worksheet into the input for approval verification. +/// +/// The supplied approval must already bind the current complete proposal records. +/// This function validates that binding without creating or renewing authority. +/// The worksheet must independently match the current proposal identity; changing +/// approval coordinates cannot refresh an older worksheet. Successful conversion +/// is still unverified input, not complete-library, full-text or write authority. +pub fn reviewed_golden_set_from_worksheet( + report: &ClassificationReport, + worksheet: &StewardReviewWorksheet, + approval: GoldenSetApproval, +) -> Result { + let expected = + build_steward_review_worksheet(report).map_err(|_| EvaluationError::InvalidReview)?; + if worksheet.decisions.len() != expected.decisions.len() { + return Err(EvaluationError::IncompleteReview); + } + if approval.receipt_id.trim().is_empty() + || approval.reviewer_subject.trim().is_empty() + || approval.proposal_digest.trim().is_empty() + || worksheet.rule_revision.trim().is_empty() + || worksheet.snapshot_digest.trim().is_empty() + || worksheet.proposal_digest.trim().is_empty() + { + return Err(EvaluationError::InvalidReview); + } + if approval.library_version != worksheet.library_version + || approval.rule_revision != worksheet.rule_revision + || approval.snapshot_digest != worksheet.snapshot_digest + || approval.proposal_digest != classification_proposal_digest(report) + || approval.snapshot_items != worksheet.snapshot_items + { + return Err(EvaluationError::SnapshotMismatch); + } + if worksheet.library_version != expected.library_version + || worksheet.rule_revision != expected.rule_revision + || worksheet.snapshot_digest != expected.snapshot_digest + || worksheet.proposal_digest != expected.proposal_digest + || worksheet.snapshot_items != expected.snapshot_items + { + return Err(EvaluationError::SnapshotMismatch); + } + + let mut labels = Vec::with_capacity(worksheet.decisions.len()); + for (decision, expected_decision) in worksheet.decisions.iter().zip(expected.decisions) { + if decision.item_key != expected_decision.item_key + || decision.item_version != expected_decision.item_version + || decision.proposed_disposition != expected_decision.proposed_disposition + || decision.abstention_reason != expected_decision.abstention_reason + { + return Err(EvaluationError::InvalidReview); + } + let expected_disposition = decision + .reviewed_disposition + .ok_or(EvaluationError::IncompleteReview)?; + if expected_disposition == Disposition::NeedsStewardReview { + return Err(EvaluationError::InvalidExpectedDisposition); + } + labels.push(GoldenLabel::new( + decision.item_key.clone(), + expected_disposition, + )); + } + if labels.is_empty() { + return Err(EvaluationError::IncompleteReview); + } + + Ok(ReviewedGoldenSet { approval, labels }) +} + /// Computes the canonical content identity verified by a golden-set approval. pub fn classification_snapshot_digest(report: &ClassificationReport) -> String { report.snapshot_digest.clone() diff --git a/crates/conceptweave-zotero/tests/steward_review_finalization.rs b/crates/conceptweave-zotero/tests/steward_review_finalization.rs new file mode 100644 index 0000000..27e6a05 --- /dev/null +++ b/crates/conceptweave-zotero/tests/steward_review_finalization.rs @@ -0,0 +1,404 @@ +use conceptweave_zotero::{ + Disposition, EvaluationError, GoldenSetApproval, ItemData, ZoteroItem, + build_steward_review_worksheet, classification_proposal_digest, classify_snapshot, + reviewed_golden_set_from_worksheet, +}; + +fn report() -> conceptweave_zotero::ClassificationReport { + classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + ZoteroItem { + source_record: None, + key: "B".into(), + version: 8, + data: ItemData { + item_type: "book".into(), + title: "unknown vocabulary".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }, + ZoteroItem { + source_record: None, + key: "A".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![], + }, + }, + ], + ) +} + +fn approval( + report: &conceptweave_zotero::ClassificationReport, + worksheet: &conceptweave_zotero::StewardReviewWorksheet, +) -> GoldenSetApproval { + GoldenSetApproval { + receipt_id: "review-receipt".into(), + reviewer_subject: "steward-subject".into(), + library_version: worksheet.library_version, + rule_revision: worksheet.rule_revision.clone(), + snapshot_digest: worksheet.snapshot_digest.clone(), + proposal_digest: classification_proposal_digest(report), + snapshot_items: worksheet.snapshot_items.clone(), + } +} + +fn complete_worksheet() -> conceptweave_zotero::StewardReviewWorksheet { + let mut worksheet = build_steward_review_worksheet(&report()).unwrap(); + for decision in &mut worksheet.decisions { + decision.reviewed_disposition = Some(Disposition::Generation); + } + worksheet +} + +#[test] +fn complete_worksheet_becomes_a_snapshot_bound_golden_set() { + let worksheet = complete_worksheet(); + + let golden = + reviewed_golden_set_from_worksheet(&report(), &worksheet, approval(&report(), &worksheet)) + .unwrap(); + + assert_eq!(golden.labels.len(), 2); + assert_eq!(golden.labels[0].item_key, "A"); + assert_eq!(golden.labels[1].item_key, "B"); + assert_eq!(golden.approval.snapshot_items, worksheet.snapshot_items); +} + +#[test] +fn finalization_rejects_each_invalid_identity_coordinate() { + let worksheet = complete_worksheet(); + + let mut invalid_report = report(); + invalid_report.rule_revision = ""; + assert_eq!( + reviewed_golden_set_from_worksheet( + &invalid_report, + &worksheet, + approval(&report(), &worksheet) + ), + Err(EvaluationError::InvalidReview) + ); + + let mut invalid_approval = approval(&report(), &worksheet); + invalid_approval.receipt_id.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, invalid_approval), + Err(EvaluationError::InvalidReview) + ); + let mut invalid_approval = approval(&report(), &worksheet); + invalid_approval.reviewer_subject.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, invalid_approval), + Err(EvaluationError::InvalidReview) + ); + + let mut invalid = worksheet.clone(); + invalid.rule_revision.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + let mut invalid = worksheet.clone(); + invalid.snapshot_digest.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + + let mut invalid_approval = approval(&report(), &worksheet); + invalid_approval.library_version += 1; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, invalid_approval), + Err(EvaluationError::SnapshotMismatch) + ); + let mut invalid_approval = approval(&report(), &worksheet); + invalid_approval.rule_revision.push_str("-changed"); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, invalid_approval), + Err(EvaluationError::SnapshotMismatch) + ); + let mut invalid_approval = approval(&report(), &worksheet); + invalid_approval.snapshot_items.pop(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, invalid_approval), + Err(EvaluationError::SnapshotMismatch) + ); + + let mut invalid = worksheet.clone(); + invalid.snapshot_items[0].item_key.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::SnapshotMismatch) + ); + let mut invalid = worksheet.clone(); + invalid.snapshot_items[1] = invalid.snapshot_items[0].clone(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::SnapshotMismatch) + ); + + let mut invalid = worksheet.clone(); + invalid.library_version += 1; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::SnapshotMismatch) + ); + let mut invalid = worksheet.clone(); + invalid.rule_revision.push_str("-changed"); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::SnapshotMismatch) + ); + let mut invalid = worksheet.clone(); + invalid.snapshot_digest.push_str("-changed"); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::SnapshotMismatch) + ); + + let mut invalid = worksheet.clone(); + invalid.decisions[0].item_key.clear(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + let mut invalid = worksheet.clone(); + invalid.decisions[0].item_version += 1; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + let mut invalid = worksheet.clone(); + invalid.decisions[0].proposed_disposition = Disposition::AlignmentVersioning; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + + let mut invalid = worksheet.clone(); + invalid.decisions[0].abstention_reason = + Some(conceptweave_zotero::AbstentionReason::NoDeterministicRuleMatch); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + let mut invalid = worksheet.clone(); + invalid.decisions[1].abstention_reason = None; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidReview) + ); + + let mut invalid = worksheet.clone(); + invalid.decisions.pop(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::IncompleteReview) + ); + + let empty_report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ZoteroItem { + source_record: None, + key: "NOTE".into(), + version: 1, + data: ItemData { + item_type: "note".into(), + title: String::new(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }], + ); + let empty = build_steward_review_worksheet(&empty_report).unwrap(); + assert_eq!( + reviewed_golden_set_from_worksheet(&empty_report, &empty, approval(&empty_report, &empty)), + Err(EvaluationError::IncompleteReview) + ); +} + +#[test] +fn finalization_rejects_incomplete_invalid_or_mismatched_review() { + let worksheet = build_steward_review_worksheet(&report()).unwrap(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &worksheet, approval(&report(), &worksheet)), + Err(EvaluationError::IncompleteReview) + ); + + let mut invalid = worksheet.clone(); + invalid.decisions[0].reviewed_disposition = Some(Disposition::NeedsStewardReview); + invalid.decisions[1].reviewed_disposition = Some(Disposition::Generation); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &invalid, approval(&report(), &invalid)), + Err(EvaluationError::InvalidExpectedDisposition) + ); + + let mut complete = worksheet.clone(); + for decision in &mut complete.decisions { + decision.reviewed_disposition = Some(Disposition::Generation); + } + let mut mismatched = approval(&report(), &complete); + mismatched.snapshot_digest.push_str("-changed"); + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &complete, mismatched), + Err(EvaluationError::SnapshotMismatch) + ); + + complete.decisions[1].item_key = complete.decisions[0].item_key.clone(); + complete.decisions[1].item_version = complete.decisions[0].item_version; + assert_eq!( + reviewed_golden_set_from_worksheet(&report(), &complete, approval(&report(), &complete)), + Err(EvaluationError::InvalidReview) + ); +} + +#[test] +fn finalization_rejects_changed_review_evidence_under_the_original_approval() { + for alter_title in [false, true] { + let mut report = report(); + let worksheet = complete_worksheet(); + let original_approval = approval(&report, &worksheet); + let golden = + reviewed_golden_set_from_worksheet(&report, &worksheet, original_approval.clone()) + .unwrap(); + assert_eq!(golden.approval, original_approval); + + if alter_title { + report.classified_items[0].title.push_str(" changed"); + } else { + report.classified_items[0].evidence.field_values.clear(); + } + let mut rebuilt_worksheet = build_steward_review_worksheet(&report).unwrap(); + for decision in &mut rebuilt_worksheet.decisions { + decision.reviewed_disposition = Some(Disposition::Generation); + } + assert_eq!( + reviewed_golden_set_from_worksheet(&report, &rebuilt_worksheet, original_approval), + Err(EvaluationError::SnapshotMismatch) + ); + } +} + +#[test] +fn locally_rebound_finalization_does_not_renew_independent_approval() { + let mut report = report(); + let mut worksheet = complete_worksheet(); + let issued_set = + reviewed_golden_set_from_worksheet(&report, &worksheet, approval(&report, &worksheet)) + .unwrap(); + assert!( + conceptweave_zotero::evaluate_reviewed_golden_set(&report, &issued_set, |value| value + == &issued_set) + .is_ok() + ); + report.classified_items[0].title.push_str(" changed"); + worksheet.proposal_digest = classification_proposal_digest(&report); + let rebound = + reviewed_golden_set_from_worksheet(&report, &worksheet, approval(&report, &worksheet)) + .unwrap(); + assert_eq!( + conceptweave_zotero::evaluate_reviewed_golden_set(&report, &rebound, |value| value + == &issued_set), + Err(EvaluationError::UnverifiedApproval) + ); +} + +#[test] +fn pending_source_conversion_does_not_prove_complete_review() { + let items = [("A", "book"), ("source", "attachment")] + .into_iter() + .map(|(key, item_type)| { + serde_json::from_value::(serde_json::json!({ + "key": key, "version": 7, + "data": {"itemType": item_type, "title": "synthetic ontology learning"} + })) + .unwrap() + }) + .collect(); + let report = classify_snapshot("9.0.6".into(), None, 42, items); + let mut worksheet = build_steward_review_worksheet(&report).unwrap(); + worksheet.decisions[0].reviewed_disposition = Some(Disposition::Generation); + let local_set = + reviewed_golden_set_from_worksheet(&report, &worksheet, approval(&report, &worksheet)) + .unwrap(); + assert_eq!(report.pending_source_item_keys, ["source"]); + assert_eq!( + conceptweave_zotero::evaluate_complete_reviewed_classification(&report, &local_set, |_| { + panic!("pending scope must not contact governance") + }), + Err(EvaluationError::IncompleteReview) + ); +} + +#[test] +fn finalization_rejects_stale_worksheet_even_with_current_approval_coordinates() { + for changed_field in 0..3 { + let mut report = report(); + let worksheet = complete_worksheet(); + match changed_field { + 0 => report.classified_items[0].title.push_str(" changed"), + 1 => report.classified_items[0].evidence.field_values.clear(), + _ => report.classified_items[0].review_abstract_note = Some("changed context".into()), + } + let current_receipt = approval(&report, &worksheet); + assert_eq!( + reviewed_golden_set_from_worksheet(&report, &worksheet, current_receipt), + Err(EvaluationError::SnapshotMismatch) + ); + } +} + +#[test] +fn finalization_rejects_blank_or_replaced_worksheet_binding() { + let report = report(); + for (binding, expected) in [ + ("", EvaluationError::InvalidReview), + (" ", EvaluationError::InvalidReview), + ("sha256:replaced", EvaluationError::SnapshotMismatch), + ] { + let mut worksheet = complete_worksheet(); + worksheet.proposal_digest = binding.into(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report, &worksheet, approval(&report, &worksheet)), + Err(expected) + ); + } +} + +#[test] +fn finalization_rejects_missing_or_replaced_proposal_binding() { + let report = report(); + let worksheet = complete_worksheet(); + for (digest, expected) in [ + ("", EvaluationError::InvalidReview), + (" ", EvaluationError::InvalidReview), + ("sha256:replaced", EvaluationError::SnapshotMismatch), + ] { + let mut receipt = approval(&report, &worksheet); + receipt.proposal_digest = digest.into(); + assert_eq!( + reviewed_golden_set_from_worksheet(&report, &worksheet, receipt), + Err(expected) + ); + } +} diff --git a/docs/PRD.md b/docs/PRD.md index f896380..1f51bb5 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -87,6 +87,8 @@ PR #21 retains validated delayed reads without writes and complete observed meta The Zotero 10+ adapter can accept a caller-owned API key and server identity at runtime or consume one successful, user-approved Local API authorization. Authorization sends one bounded application name and the expected server identity to the fixed loopback endpoint; only a same-server bounded response that explicitly reports denial is classified as the user's decision. Denial and rate limiting return immediately without another prompt or automatic retry. The private 32-character key is neither serializable nor printable. Authorization, read, and write responses bind to the expected server before status classification; writes name expired authorization and matching-server stale preconditions separately. Thin public execution boundaries connect the adapter to the reviewed write and rollback cores without duplicating mutation logic. Synthetic transport evidence does not satisfy AC6's approved live Zotero 10 authorization, write, and rollback requirement. A local steward worksheet must bind the library version, rule revision, complete raw-snapshot digest, current proposal-and-retained-source digest, every observed parent/child item revision, and one blank decision slot per classified bibliographic item. It repeats item identity, proposal, and abstention reason only; titles, abstracts, tags, collections, and matched evidence remain in the separate sensitive report. Shared inventory validation rejects omitted source records, hidden pending relationships and inconsistent identity before construction. Valid unresolved sources do not prevent starting review, but prevent claiming completion. Old worksheets without the content binding require regeneration, never automatic approval backfill. +After every decision is filled, worksheet finalization must verify the governance receipt coordinates, unique item identities and revisions, proposal/abstention consistency, and non-abstention truth labels before producing a reviewed golden set. Missing decisions remain incomplete and cannot reach external approval verification. +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. 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. diff --git a/docs/TRD.md b/docs/TRD.md index 875b633..8df75fb 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -111,6 +111,8 @@ A successful classification report carries an `audit_summary` whose snapshot, bi `conceptweave-zotero --worksheet /tmp/REPORT.json /tmp/WORKSHEET.json` validates both canonical destinations and rejects aliases before reading one live snapshot. Both artifacts are built and serialized before the first file write. Writes are sequential, not an atomic publication. Any write failure propagates without pathname deletion or implicit buffer-flush retry. A complete report and an empty/partial worksheet may remain; that is not a completed pair. Inspect retained owner-only files and use new output paths for another capture. Never automatically overwrite or infer approval from a surviving artifact. A successful flush is not a crash-durability guarantee. The review worksheet is a deterministic item-key-ordered projection of the report. It binds library/rule revisions, raw-snapshot digest, required `proposal_digest` from the existing v2 scope hash, complete item coordinates, proposal, abstention reason, and one initially empty decision per bibliographic item. Construction reuses `validate_classification_report`, mapped to `WorksheetError::InvalidReport`, before worksheet-specific nonblank identity and abstention checks. This avoids a second drifting audit implementation while admitting structurally valid pending source evidence. It deliberately omits bibliographic text and matched evidence; stewards consult the owner-only report by item key. Missing proposal binding fails deserialization. Subsequent progress, application and finalization owners must compare this field to the recomputed report binding; present-but-blank or rewritten values are not authority. Legacy worksheets must be regenerated, and independent approval remains separate. +The finalization function consumes the filled worksheet plus a governance approval receipt. It rejects blank authority metadata, missing or mismatched proposal bindings, coordinate drift, duplicate or unknown decision keys, missing decisions, abstention as approved truth, and tampered proposal/reason pairs. It recomputes the complete current proposal digest, including title and evidence fields omitted from the worksheet, and compares it with the supplied approval without replacing that approval. Its output reuses the existing reviewed-golden-set evaluator; finalization itself does not verify external authority. +It also rejects a blank worksheet `proposal_digest` as `InvalidReview` and compares that field with the freshly built expected worksheet as `SnapshotMismatch`, retaining existing cardinality and approval error precedence. The converter does not accept an updated receipt as proof that old decisions reviewed changed content. A caller can construct self-consistent unverified data, so the evaluator still authenticates the entire reviewed set against independent evidence. Pending sources are admitted for preparation but rejected by complete evaluation before governance is contacted. Later extracted worksheet validators must preserve this comparison. 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 ecb7082..931021c 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -5,6 +5,8 @@ ## Context +Proposed September 7 finalization amendment: when carrying completed metadata decisions between saved artifacts, changed report evidence plus freshly supplied receipt coordinates must not refresh a stale worksheet. We choose two checks in the existing converter—nonblank worksheet proposal identity and equality with the recomputed expected worksheet—rather than a second approval mechanism or automatically rebinding old decisions. RED `f02631e` reproduces both stale-content and blank/replaced-binding admission; `d44b9fe` closes them while preserving prior error precedence. The cost is explicit worksheet regeneration/review after changed source context. Self-consistent local artifacts remain unverified: `e90a02b` demonstrates that locally rewritten digests still fail independent original-receipt verification and that pending sources prevent complete evaluation even after local conversion. This decision grants neither full-text nor Zotero write authority. Later worksheet comparators must inherit the binding check, and protected/runtime evidence remains outstanding. + Proposed September 7 export-failure amendment: when exporting sensitive report/worksheet pairs, a failed write may race with pathname replacement, and destroying a buffered writer may write pending bytes after failure. We choose to preserve artifacts, disassemble the existing buffer without flushing, and propagate the original error, rejecting pathname cleanup or an automatic retry. This extends the existing private-creation policy to the common output writer and the pair's second-file failure. The cost is retained empty/partial files and operator inspection; sequential export is not a transaction or crash-durable publication. Both canonical destinations must differ before the Local API read, including aliases such as `/tmp` and `/private/tmp`. RED `68575a6` proves replacement deletion and implicit drop-flush; `ab391b2` removes both behaviors. RED `fb5b5e5` proves alias collision; `e928858` rejects it before snapshot capture. Later CLI owners must inherit this implementation instead of restoring cleanup. No Zotero mutation or approval authority follows from local output. CWL needs a reproducible inventory of ontology research without turning keyword matches into authoritative library organization. The current desktop is Zotero 9.0.6, whose Local API supports reads but not writes. Zotero documents Local API writes only for Zotero 10+, where they require user-granted authorization and `Zotero-Server-ID`; this slice therefore has no mutation capability. The library is mutable while pagination is in progress, duplicate metadata does not prove that two records should be merged, and the local report contains titles and item keys that must not be written into the repository. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 31aba87..864e14c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -6,6 +6,16 @@ This file records code-current product and technical gaps. Exact PR/check/run co ## September 6 source inventory checkpoint +### September 7 PR27 finalization continuity repair + +Final source `da2556b` passes 172 tests/25 suites including three doctests, strict all-target Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged pinned coverage gate passes 295/295 functions, 2,611/2,611 normalized regions and 468/468 normalized branches. Raw coverage remains 3,436/3,498 lines, 5,214/5,321 regions and 423/468 branches, not 100%. Logs: `/tmp/conceptweave-pr27-verified.log` and `/tmp/conceptweave-pr27-{clippy,rustdoc,coverage}-verified.log`. Both baseline and integration finished before their successors were edited; the final documentation commit receives its own full verification. + +Original PR27 `3df0c124f390797bacaba8ffdf229f502b0e9bf3` passed 133 tests/25 suites. Ordinary merge `5fa34b4` retains it and PR26 `7ad6386de13e19bb57fc4519141ac67c7b8bf92b`; integrated tests passed 168/25. RED `f02631e` compiled with 5 passing and 2 failing tests: blank/replaced worksheet bindings and stale worksheet decisions with a current report/receipt were admitted. `d44b9fe` adds two existing-boundary checks: blank worksheet binding is `InvalidReview`; mismatch with the recomputed expected worksheet is `SnapshotMismatch`. Prior cardinality and approval error precedence is preserved. + +`e90a02b` verifies the distinction between local conversion and independent authority: locally rewriting both digests can produce self-consistent input, but the original independently verified whole-set receipt still rejects it. Valid pending-source conversion is retained for preparation, while complete evaluation refuses it before governance. Tests cover changed title, evidence and review context. Independent read-only review found no additional production finding; `da2556b` documents this boundary in the public API, PRD, TRD and Proposed ADR0006. Final exact-source verification is recorded below. + +This does not complete the library campaign: actual decisions and independent approvals remain 0/3,715 plus four unresolved standalone sources. No real metadata, labels or approval was generated by these synthetic tests. Root and later extracted worksheet validators still need the repair; next PR28 report roundtrip must retain exact binding and source scope. Visual Inspection was attempted but the Mac remains locked, so no new screen evidence is claimed. Local verification is not hosted GREEN, independent protected approval, protected merge, release, full-text authority or Zotero mutation. + ### September 7 PR26 private export repair Final source `58ff5985890d5a0b4aaadaa1f8d604e1bc96a1e2` passes 163 tests/24 suites including three doctests, strict Clippy, warnings-denied rustdoc, formatting, CI contract and diff checks. The unchanged pinned coverage gate passes 294/294 functions, 2,536/2,536 normalized regions and 422/422 normalized branches. Raw coverage remains 3,382/3,444 lines, 5,139/5,246 regions and 377/422 branches, not 100%. Logs: `/tmp/conceptweave-pr26-verified.log` and `/tmp/conceptweave-pr26-{clippy,rustdoc,coverage}-verified.log`. The earlier coverage failure is retained in `/tmp/conceptweave-pr26-coverage.log`; it is not a successful checkpoint.