From b3fd61c9176bcda17ecb00ca03cc7663cc092724 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:12:32 +0900 Subject: [PATCH 01/14] test(zotero): require private report permissions --- crates/conceptweave-zotero/src/main.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index dd89c289..9b8ac63e 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -134,4 +134,18 @@ mod tests { fs::remove_file(link).unwrap(); fs::remove_file(target).unwrap(); } + + #[cfg(unix)] + #[test] + fn report_output_is_owner_readable_and_writable_only() { + use std::os::unix::fs::PermissionsExt; + + let output = unique_temp_path("private"); + let _ = fs::remove_file(&output); + let file = create_report_file(&output).unwrap(); + let mode = file.metadata().unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + drop(file); + fs::remove_file(output).unwrap(); + } } From c7f954d63adae5bcc061643d6758baeaff6ee35c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:12:51 +0900 Subject: [PATCH 02/14] fix(zotero): create local reports privately --- crates/conceptweave-zotero/src/main.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 9b8ac63e..405b6afd 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -3,7 +3,7 @@ use conceptweave_zotero::read_local_snapshot; use std::env; -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; @@ -46,6 +46,17 @@ fn validate_output_path(raw: &str) -> io::Result { Ok(path) } +fn create_report_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + #[cfg_attr(coverage_nightly, coverage(off))] fn main() -> Result<(), Box> { let output = env::args() @@ -56,10 +67,7 @@ fn main() -> Result<(), Box> { if report.zotero_version.starts_with("9.") { eprintln!("Zotero 9 Local API is read-only; writing a local proposal report only"); } - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(output)?; + let file = create_report_file(&output)?; let mut writer = BufWriter::new(file); serde_json::to_writer_pretty(&mut writer, &report)?; writer.flush()?; From 2563b55659aa0f4c3014dc2f0de94df8fad6b5a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:14:32 +0900 Subject: [PATCH 03/14] docs(zotero): record private report boundary --- CHANGELOG.md | 1 + README.md | 2 +- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d89ce8c5..ec59d9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to ConceptWeave are documented here. - Standards and research doctoring covering stable W3C ontology standards, 2026 RDF/SHACL work in progress, Apache Ossie, and recent LLM ontology-engineering research. - Read-only delayed reconciliation receipts for indeterminate Zotero rollback operations. - Minimal, nonduplicated local abstract context for Zotero items that require steward classification. +- Owner-only file permissions for sensitive local Zotero classification reports. ### Security diff --git a/README.md b/README.md index 046d2544..13a5c0da 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ With Zotero running locally: cargo +1.98.0 run --bin conceptweave-zotero -- /tmp/conceptweave-zotero-classification.json ``` -The command reads one stable library-version snapshot and creates a local, reviewable JSON report. Output is restricted to a new direct child of canonical `/tmp` or the system temporary directory, and the command never changes Zotero records. +The command reads one stable library-version snapshot and creates a local, reviewable JSON report. Output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory, and the command never changes Zotero records. [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave) diff --git a/docs/TRD.md b/docs/TRD.md index a60d360e..3e61da7b 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -70,7 +70,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi 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. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; 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. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation and its complete metadata are retained separately for operator reconciliation. The delayed reconciliation boundary performs exactly one server-bound read and no write. It treats an exact item revision plus expected metadata as unchanged even if unrelated library changes advanced the library version, and treats restoration metadata as restored only at a newer item revision; every identity, metadata, or version ambiguity remains indeterminate. Already consumed evidence fails preflight on reuse. +The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new owner-readable/writable (`0600`) direct child of canonical `/tmp` or the operating system temporary directory; 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. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation and its complete metadata are retained separately for operator reconciliation. The delayed reconciliation boundary performs exactly one server-bound read and no write. It treats an exact item revision plus expected metadata as unchanged even if unrelated library changes advanced the library version, and treats restoration metadata as restored only at a newer item revision; every identity, metadata, or version ambiguity remains indeterminate. Already consumed evidence fails preflight on reuse. The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 54a827aa..44935925 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -17,7 +17,7 @@ ConceptWeave owns a small read-only Anti-Corruption Layer from Zotero into resea The adapter links child records, emits exactly one deterministic proposed disposition per top-level bibliographic item, and abstains when evidence is weak or ambiguous. Every abstention preserves a deterministic reason distinguishing missing classification metadata, vocabulary outside the current deterministic rules, present-but-unmatched metadata, and conflicting specific disposition families. Specific rule families are evaluated together rather than by first-match priority. When evidence matches multiple families, the proposal becomes `NeedsStewardReview` and all matching evidence is retained. -Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. The report remains sensitive local material. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. +Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. The sensitive local report is created with owner-only `0600` permissions. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. Duplicate candidates become canonical references only through externally verified steward decisions bound to the raw digest, complete item-key/item-version snapshot, and exact candidate membership. Overlapping candidates form one connected component and must select one component-level canonical item. Every resulting operation retains all component source revisions and complete before/after/rollback key mappings. It changes downstream identity resolution only; classification does not merge, delete, or mutate Zotero source records. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cac353fe..7e0db491 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -46,7 +46,7 @@ The successor authorization slice adds one-shot Zotero 10 Local API authorizatio Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The 3,658-item abstention queue now preserves each nonempty abstract exactly once in its sensitive local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. +The 3,658-item abstention queue now preserves each nonempty abstract exactly once in its sensitive owner-only (`0600`) local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate with complete operation evidence retained separately and excluded from automatic retry until operator reconciliation. A later read-only reconciliation records the observed state, tolerates unrelated library-version advancement, and emits retry evidence only for an exact unchanged item; restoration metadata at a newer item revision proves current state but not causality. Reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. From 44160985083c084d5930743af3b6f9ae9c26adc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:16:37 +0900 Subject: [PATCH 04/14] fix(zotero): enforce exact private report mode --- crates/conceptweave-zotero/src/main.rs | 36 ++++++++++++++++---------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 405b6afd..35854541 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -8,13 +8,11 @@ use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; #[cfg_attr(coverage_nightly, coverage(off))] -fn allowed_output_parents() -> [PathBuf; 2] { - [ - env::temp_dir() - .canonicalize() - .expect("system temporary directory must exist"), - Path::new("/tmp").canonicalize().expect("/tmp must exist"), - ] +fn allowed_output_parents() -> io::Result> { + let mut parents = vec![env::temp_dir().canonicalize()?]; + #[cfg(unix)] + parents.push(Path::new("/tmp").canonicalize()?); + Ok(parents) } fn validate_output_path(raw: &str) -> io::Result { @@ -26,7 +24,7 @@ fn validate_output_path(raw: &str) -> io::Result { )); } - let allowed_parents = allowed_output_parents(); + let allowed_parents = allowed_output_parents()?; let parent = path.parent().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "report output has no parent") })?; @@ -47,14 +45,26 @@ fn validate_output_path(raw: &str) -> io::Result { } fn create_report_file(path: &Path) -> io::Result { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); + #[cfg(not(unix))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "private report creation requires a Unix platform", + )); + #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let file = options.mode(0o600).open(path)?; + if let Err(error) = file.set_permissions(fs::Permissions::from_mode(0o600)) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) } - options.open(path) } #[cfg_attr(coverage_nightly, coverage(off))] From 66724982629dc0d49631f51c86ab6ad91c88703a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:18:05 +0900 Subject: [PATCH 05/14] docs(zotero): scope private report support --- README.md | 2 +- docs/TRD.md | 2 +- docs/adr/0006-zotero-research-intake.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 13a5c0da..b316086f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ With Zotero running locally: cargo +1.98.0 run --bin conceptweave-zotero -- /tmp/conceptweave-zotero-classification.json ``` -The command reads one stable library-version snapshot and creates a local, reviewable JSON report. Output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory, and the command never changes Zotero records. +The command reads one stable library-version snapshot and creates a local, reviewable JSON report. On Unix, output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory; the CLI fails closed on other platforms. The command never changes Zotero records. [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave) diff --git a/docs/TRD.md b/docs/TRD.md index 3e61da7b..e9d99ea2 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -70,7 +70,7 @@ A successful classification report carries an `audit_summary` whose snapshot, bi 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. CLI output is restricted to a new owner-readable/writable (`0600`) direct child of canonical `/tmp` or the operating system temporary directory; 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. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation and its complete metadata are retained separately for operator reconciliation. The delayed reconciliation boundary performs exactly one server-bound read and no write. It treats an exact item revision plus expected metadata as unchanged even if unrelated library changes advanced the library version, and treats restoration metadata as restored only at a newer item revision; every identity, metadata, or version ambiguity remains indeterminate. Already consumed evidence fails preflight on reuse. +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. The execution core makes no call in dry-run mode; otherwise it preflights every item before the first write, advances the library precondition only from verified state, stops on the first adapter or response failure, and re-reads that item through the same boundary. A proven applied state receives reverse-ordered rollback evidence containing server identity, post-write item revision, expected post-write metadata, and the complete restoration state even when the write response was lost; a state matching neither the before nor after contract is marked indeterminate. The generic rollback core rejects operations spanning server identities before any read, then reads every receipt item at one current library version and verifies that evidence before writing. It follows receipt order, advances the library version only after a verified inverse write, and on failure re-reads the item to classify restored, unchanged, or indeterminate state. Its secret-free receipt separates restored, failed, indeterminate, not-attempted, and remaining work. Automatic retry evidence includes a failed current operation only when it is proven unchanged; an indeterminate operation and its complete metadata are retained separately for operator reconciliation. The delayed reconciliation boundary performs exactly one server-bound read and no write. It treats an exact item revision plus expected metadata as unchanged even if unrelated library changes advanced the library version, and treats restoration metadata as restored only at a newer item revision; every identity, metadata, or version ambiguity remains indeterminate. Already consumed evidence fails preflight on reuse. The Zotero 10+ transport is pinned to loopback, rejects redirects, and uses finite timeouts. A one-shot authorization POST to `/api/local/authorize` sends JSON `{ "appName": ... }`, `Content-Type: application/json`, and the expected `Zotero-Server-ID`. Application names must be nonblank and at most 128 bytes. Every authorization, read, and write response must repeat that exact server identity before its status is interpreted. A bounded `200 OK` authorization response contains a 32-byte visible-ASCII key plus the `remember` decision. A same-server `403` is classified as denial only when its bounded JSON body parses with `denied: true`; missing, malformed, oversized, or false denial evidence fails closed. `429` exposes only a safe integer `Retry-After` delta of at most one day. Neither condition retries or prompts again. The authorization wrapper is neither debug-printable nor serializable, keeps the key private, exposes only the remembered decision, and can be consumed into the existing adapter. Item responses remain capped at 1 MiB. Writes distinguish same-server `401` reauthorization from same-server `412` stale preconditions, while a different-server `412` on library, item, or write paths is a database switch; all errors remain static and secret-free. Narrow adapter functions reuse the generic write and rollback cores. Mock TCP evidence covers the wire contract, but no approved live Zotero 10 authorization, write, partial-failure, or rollback has been performed. diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 44935925..4f4dbb74 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -17,7 +17,7 @@ ConceptWeave owns a small read-only Anti-Corruption Layer from Zotero into resea The adapter links child records, emits exactly one deterministic proposed disposition per top-level bibliographic item, and abstains when evidence is weak or ambiguous. Every abstention preserves a deterministic reason distinguishing missing classification metadata, vocabulary outside the current deterministic rules, present-but-unmatched metadata, and conflicting specific disposition families. Specific rule families are evaluated together rather than by first-match priority. When evidence matches multiple families, the proposal becomes `NeedsStewardReview` and all matching evidence is retained. -Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. The sensitive local report is created with owner-only `0600` permissions. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. +Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. An abstention likewise retains its nonempty abstract so a steward can resolve unsupported or unmatched vocabulary from the same immutable report. If matched evidence already contains the abstract, the review-only field is omitted so sensitive text appears once; decided items also omit that extra copy. On supported Unix platforms, the sensitive local report is created with exact owner-only `0600` permissions after applying the process umask; other platforms fail closed. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. Duplicate candidates become canonical references only through externally verified steward decisions bound to the raw digest, complete item-key/item-version snapshot, and exact candidate membership. Overlapping candidates form one connected component and must select one component-level canonical item. Every resulting operation retains all component source revisions and complete before/after/rollback key mappings. It changes downstream identity resolution only; classification does not merge, delete, or mutate Zotero source records. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7e0db491..e5870a37 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -46,7 +46,7 @@ The successor authorization slice adds one-shot Zotero 10 Local API authorizatio Local evidence on 2026-09-04 showed Zotero 9.0.6, Local API v3/schema 42, library version 12341, 8,326 total items, and 3,719 top-level items. The corrected read-only run observed all 8,326 records at that single version and classified all 3,715 top-level bibliographic records; four top-level note/attachment/annotation records were correctly excluded. It proposed 56 adjacent-evidence records, 1 semantic-consumption bridge, and 3,658 steward-review abstentions, linked children for 3,287 records, and surfaced 49 reversible duplicate groups (18 DOI, 31 title). No live record matched multiple specific disposition families; the tested conflict path still abstains fail-closed. Token-boundary matching prevents strings such as `knowledge` from becoming false OWL evidence. These are local aggregate observations, not reviewed truth or applied Zotero changes. The report stays outside the repository. -The 3,658-item abstention queue now preserves each nonempty abstract exactly once in its sensitive owner-only (`0600`) local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. +The 3,658-item abstention queue now preserves each nonempty abstract exactly once in its sensitive local report entry, alongside the existing title, tags, collections, item revision, and abstention reason. On the supported Unix CLI, report creation restores exact owner-only (`0600`) permissions after umask application and other platforms fail closed. A live read-only rerun on 2026-09-05 retained review-only abstracts for 2,665 abstentions, found no live conflict whose matched evidence already carried an abstract, produced zero duplicate abstract copies, copied none into the 57 deterministically decided entries, observed all 8,326 records at library version 12341, and reported zero read failures. The remaining 993 abstentions have no abstract and still retain their available title/tag/collection context and explicit reason. The tested conflict path keeps its abstract only in matched evidence. This makes the complete steward workload reviewable without creating a second workload model or weakening the snapshot-bound external approval requirement. The report remains outside the repository. The golden-set evaluation contract now records aggregate precision/recall numerators and denominators, requires an externally verified governance receipt bound to the complete item-key/item-version snapshot, rejects abstention as expected truth, and retains verified revisions plus an opaque snapshot digest so detached metrics remain attributable. Item and reviewer identities stay out of its output. Successful classification reports also carry same-snapshot aggregate coverage, provenance, abstention, duplicate, disposition, and failure evidence. Connected duplicate components now produce a snapshot-bound local review manifest only after external steward verification; every operation retains all component source revisions and before/after/rollback canonical mappings while Zotero records remain unchanged. Reviewed collection/tag changes produce a default-dry-run plan bound to exact server, library, item, rule, digest, and complete metadata preconditions; externally read-only plan state prevents post-validation forgery, automatic-tag type is preserved, and Zotero 9 execute mode is rejected. The injected execution core calls nothing in dry-run mode, preflights every item before a write, stops at the first failure, reconciles a lost or invalid response with a same-boundary read, and emits rollback evidence for every item whose applied state is proven. The generic rollback executor rejects mixed-server evidence before reading, verifies all expected post-write states at one current library version before the first inverse write, follows the receipt's reverse order, advances only from verified writes, and stops with restored, failed, indeterminate, not-attempted, and remaining classifications. Unprovable state is reported as indeterminate with complete operation evidence retained separately and excluded from automatic retry until operator reconciliation. A later read-only reconciliation records the observed state, tolerates unrelated library-version advancement, and emits retry evidence only for an exact unchanged item; restoration metadata at a newer item revision proves current state but not causality. Reuse after restoration fails before writing. A fixed-loopback Zotero 10 adapter supplies stable server-pinned reads and authenticated one-item writes with atomic library/item preconditions, complete collection/tag replacement, and bounded verified responses; thin wrappers reuse both generic executors. Mock fixtures verify these contracts and secret-free failures. Korean, Japanese, Chinese, Vietnamese, Spanish, German, and French ontology-alignment metadata now have explicit fail-closed abstention coverage alongside the existing English positive case; this is safety evidence, not translated classification support. No real precision/recall, duplicate merge, write, or rollback claim exists until a steward supplies reviewed local decisions and a production authorization adapter verifies them. AC6 still requires approved live Zotero 10 write, partial-failure, and rollback evidence. Multilingual rule expansion remains a later evidence-driven change and must not reduce abstention safety. A dedicated utility repository remains unnecessary until an independently released cross-product contract exists. From fbd83a11b706b8f6347c1041a431875b93cfe92f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:19:17 +0900 Subject: [PATCH 06/14] test(zotero): cover private report failures --- crates/conceptweave-zotero/src/main.rs | 62 ++++++++++++++++++++------ 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 35854541..8cf12733 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -8,11 +8,15 @@ use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; #[cfg_attr(coverage_nightly, coverage(off))] -fn allowed_output_parents() -> io::Result> { - let mut parents = vec![env::temp_dir().canonicalize()?]; +fn allowed_output_parents() -> Vec { + let mut parents = vec![ + env::temp_dir() + .canonicalize() + .expect("system temporary directory must exist"), + ]; #[cfg(unix)] - parents.push(Path::new("/tmp").canonicalize()?); - Ok(parents) + parents.push(Path::new("/tmp").canonicalize().expect("/tmp must exist")); + parents } fn validate_output_path(raw: &str) -> io::Result { @@ -24,7 +28,7 @@ fn validate_output_path(raw: &str) -> io::Result { )); } - let allowed_parents = allowed_output_parents()?; + let allowed_parents = allowed_output_parents(); let parent = path.parent().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "report output has no parent") })?; @@ -55,18 +59,30 @@ fn create_report_file(path: &Path) -> io::Result { { use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - let file = options.mode(0o600).open(path)?; - if let Err(error) = file.set_permissions(fs::Permissions::from_mode(0o600)) { - drop(file); - let _ = fs::remove_file(path); - return Err(error); - } - Ok(file) + create_report_file_with(path, |file| { + file.set_permissions(fs::Permissions::from_mode(0o600)) + }) } } +#[cfg(unix)] +fn create_report_file_with( + path: &Path, + set_permissions: impl FnOnce(&File) -> io::Result<()>, +) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let file = options.mode(0o600).open(path)?; + if let Err(error) = set_permissions(&file) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) +} + #[cfg_attr(coverage_nightly, coverage(off))] fn main() -> Result<(), Box> { let output = env::args() @@ -165,5 +181,23 @@ mod tests { assert_eq!(mode, 0o600); drop(file); fs::remove_file(output).unwrap(); + + let existing = unique_temp_path("private-existing"); + let _ = fs::remove_file(&existing); + fs::write(&existing, b"existing").unwrap(); + assert!(create_report_file(&existing).is_err()); + fs::remove_file(existing).unwrap(); + + let rejected = unique_temp_path("private-permission-error"); + let _ = fs::remove_file(&rejected); + let error = create_report_file_with(&rejected, |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected permission failure", + )) + }) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(!rejected.exists()); } } From 5f81ad9b1562bd4906698a345b49d5d275433396 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:19:49 +0900 Subject: [PATCH 07/14] refactor(zotero): share report permission check path --- crates/conceptweave-zotero/src/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 8cf12733..18b0a40c 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -57,18 +57,21 @@ fn create_report_file(path: &Path) -> io::Result { #[cfg(unix)] { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - - create_report_file_with(path, |file| { - file.set_permissions(fs::Permissions::from_mode(0o600)) - }) + create_report_file_with(path, set_owner_only_permissions) } } +#[cfg(unix)] +fn set_owner_only_permissions(file: &File) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + file.set_permissions(fs::Permissions::from_mode(0o600)) +} + #[cfg(unix)] fn create_report_file_with( path: &Path, - set_permissions: impl FnOnce(&File) -> io::Result<()>, + set_permissions: fn(&File) -> io::Result<()>, ) -> io::Result { use std::os::unix::fs::OpenOptionsExt; From 6c22e207f44cd6c3395811a36f62fad20a7d1a8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:29:55 +0900 Subject: [PATCH 08/14] test(zotero): scope temp path assertions by platform --- crates/conceptweave-zotero/src/main.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 18b0a40c..d8601264 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -8,6 +8,7 @@ use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; #[cfg_attr(coverage_nightly, coverage(off))] +/// Returns canonical directories in which a sensitive report may be created. fn allowed_output_parents() -> Vec { let mut parents = vec![ env::temp_dir() @@ -19,6 +20,7 @@ fn allowed_output_parents() -> Vec { parents } +/// Validates that a report path is a new direct child of an allowed temp directory. fn validate_output_path(raw: &str) -> io::Result { let path = PathBuf::from(raw); if !path.is_absolute() { @@ -48,6 +50,7 @@ fn validate_output_path(raw: &str) -> io::Result { Ok(path) } +/// Creates a new sensitive report file or fails closed on unsupported platforms. fn create_report_file(path: &Path) -> io::Result { #[cfg(not(unix))] return Err(io::Error::new( @@ -62,6 +65,7 @@ fn create_report_file(path: &Path) -> io::Result { } #[cfg(unix)] +/// Restores exact owner-only permissions after process umask application. fn set_owner_only_permissions(file: &File) -> io::Result<()> { use std::os::unix::fs::PermissionsExt; @@ -69,6 +73,7 @@ fn set_owner_only_permissions(file: &File) -> io::Result<()> { } #[cfg(unix)] +/// Creates a private file and removes it if final permission enforcement fails. fn create_report_file_with( path: &Path, set_permissions: fn(&File) -> io::Result<()>, @@ -87,6 +92,7 @@ fn create_report_file_with( } #[cfg_attr(coverage_nightly, coverage(off))] +/// Reads one Zotero snapshot and writes its sensitive local proposal report. fn main() -> Result<(), Box> { let output = env::args() .nth(1) @@ -137,12 +143,18 @@ mod tests { .is_err() ); - let conventional = Path::new("/tmp").join(format!( - "conceptweave-zotero-{}-conventional.json", - std::process::id() - )); - let _ = fs::remove_file(&conventional); - assert!(validate_output_path(conventional.to_str().unwrap()).is_ok()); + #[cfg(unix)] + { + let conventional = Path::new("/tmp").join(format!( + "conceptweave-zotero-{}-conventional.json", + std::process::id() + )); + let _ = fs::remove_file(&conventional); + assert!(validate_output_path(conventional.to_str().unwrap()).is_ok()); + } + + #[cfg(not(unix))] + assert!(validate_output_path("/tmp/conceptweave-zotero.json").is_err()); let nested_dir = env::temp_dir().join(format!("conceptweave-zotero-{}-nested", std::process::id())); From 7cfc7fb584fe664f76180bd67297c1b8ebb9681f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:28:04 +0900 Subject: [PATCH 09/14] test(zotero): expose private output replacement deletion --- crates/conceptweave-zotero/src/main.rs | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index d8601264..a5bc9d33 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -215,4 +215,57 @@ mod tests { assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); assert!(!rejected.exists()); } + + #[cfg(unix)] + #[test] + fn permission_failure_preserves_a_replacement_at_the_output_path() { + use std::os::unix::fs::PermissionsExt; + let output = unique_temp_path("permission-replaced"); + let retained = unique_temp_path("permission-original"); + assert!(!output.exists() && !retained.exists()); + let error = create_report_file_with(&output, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o077, 0); + fs::rename( + unique_temp_path("permission-replaced"), + unique_temp_path("permission-original"), + )?; + let mut replacement = OpenOptions::new() + .write(true) + .create_new(true) + .open(unique_temp_path("permission-replaced"))?; + replacement.write_all(b"unrelated replacement")?; + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected failure", + )) + }) + .unwrap_err(); + let preserved = fs::read(&output).ok(); + if output.exists() { + fs::remove_file(&output).unwrap(); + } + fs::remove_file(retained).unwrap(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!( + preserved.as_deref(), + Some(b"unrelated replacement".as_slice()) + ); + } + + #[cfg(unix)] + #[test] + fn report_output_returns_the_checked_canonical_parent() { + let output = Path::new("/tmp").join(format!( + "conceptweave-zotero-{}-canonical.json", + std::process::id() + )); + let expected = Path::new("/tmp") + .canonicalize() + .unwrap() + .join(output.file_name().unwrap()); + assert_eq!( + validate_output_path(output.to_str().unwrap()).unwrap(), + expected + ); + } } From 25154b6e499ddf6c0ed965179017e359fde95fec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:15:25 +0900 Subject: [PATCH 10/14] fix(zotero): rebuild output path from canonical parent (cherry picked from commit 86288cdf5959040a95221c2ca2d99e243d25dc27) --- crates/conceptweave-zotero/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index a5bc9d33..9d559589 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -41,13 +41,17 @@ fn validate_output_path(raw: &str) -> io::Result { "report output must be a direct child of the system temp directory", )); } - if fs::symlink_metadata(&path).is_ok() { + let file_name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "report output has no file name") + })?; + let validated_path = resolved_parent.join(file_name); + if fs::symlink_metadata(&validated_path).is_ok() { return Err(io::Error::new( io::ErrorKind::AlreadyExists, "report output must not already exist or be a symlink", )); } - Ok(path) + Ok(validated_path) } /// Creates a new sensitive report file or fails closed on unsupported platforms. From 48d7068099f37f82719ea12d7a24d419bc3e9c18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:28:37 +0900 Subject: [PATCH 11/14] fix(zotero): avoid unlinking replaced private report paths --- crates/conceptweave-zotero/src/main.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 9d559589..2c035d52 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -42,7 +42,10 @@ fn validate_output_path(raw: &str) -> io::Result { )); } let file_name = path.file_name().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "report output has no file name") + io::Error::new( + io::ErrorKind::InvalidInput, + "report output has no file name", + ) })?; let validated_path = resolved_parent.join(file_name); if fs::symlink_metadata(&validated_path).is_ok() { @@ -77,7 +80,7 @@ fn set_owner_only_permissions(file: &File) -> io::Result<()> { } #[cfg(unix)] -/// Creates a private file and removes it if final permission enforcement fails. +/// Creates a private file; failure leaves an empty file rather than unlinking a raced path. fn create_report_file_with( path: &Path, set_permissions: fn(&File) -> io::Result<()>, @@ -87,11 +90,7 @@ fn create_report_file_with( let mut options = OpenOptions::new(); options.write(true).create_new(true); let file = options.mode(0o600).open(path)?; - if let Err(error) = set_permissions(&file) { - drop(file); - let _ = fs::remove_file(path); - return Err(error); - } + set_permissions(&file)?; Ok(file) } @@ -217,7 +216,8 @@ mod tests { }) .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); - assert!(!rejected.exists()); + assert_eq!(fs::metadata(&rejected).unwrap().len(), 0); + fs::remove_file(rejected).unwrap(); } #[cfg(unix)] From c0db7ff3997e644a8313191ae3b0a45c012d070f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:29:41 +0900 Subject: [PATCH 12/14] test(zotero): expect canonical system temp output path --- crates/conceptweave-zotero/src/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 2c035d52..4ee0f747 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -130,6 +130,11 @@ mod tests { assert_eq!( validate_output_path(allowed.to_str().unwrap()).unwrap(), allowed + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(allowed.file_name().unwrap()) ); assert!(validate_output_path("relative.json").is_err()); From 522e46b8a997ed5499ea5518ad4461b50e9ef54e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:32:10 +0900 Subject: [PATCH 13/14] test(zotero): cover nameless output and simplify fixture cleanup --- crates/conceptweave-zotero/src/main.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/conceptweave-zotero/src/main.rs b/crates/conceptweave-zotero/src/main.rs index 4ee0f747..8924fb18 100644 --- a/crates/conceptweave-zotero/src/main.rs +++ b/crates/conceptweave-zotero/src/main.rs @@ -139,6 +139,10 @@ 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(); + 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()); assert!( validate_output_path( @@ -231,7 +235,8 @@ mod tests { use std::os::unix::fs::PermissionsExt; let output = unique_temp_path("permission-replaced"); let retained = unique_temp_path("permission-original"); - assert!(!output.exists() && !retained.exists()); + assert!(!output.exists()); + assert!(!retained.exists()); let error = create_report_file_with(&output, |file| { assert_eq!(file.metadata()?.permissions().mode() & 0o077, 0); fs::rename( @@ -250,9 +255,7 @@ mod tests { }) .unwrap_err(); let preserved = fs::read(&output).ok(); - if output.exists() { - fs::remove_file(&output).unwrap(); - } + let _ = fs::remove_file(&output); fs::remove_file(retained).unwrap(); assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); assert_eq!( From 2d32f96740c708c3f7c13b392386f8bc7a878746 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:33:38 +0900 Subject: [PATCH 14/14] docs(research): record private output failure policy and evidence --- README.md | 4 +++ docs/adr/0006-zotero-research-intake.md | 14 +++++++++ docs/product-technical-gap-baseline.md | 39 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/README.md b/README.md index b316086f..9d12d774 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ cargo +1.98.0 run --bin conceptweave-zotero -- /tmp/conceptweave-zotero-classifi The command reads one stable library-version snapshot and creates a local, reviewable JSON report. On Unix, output is restricted to a new owner-only (`0600`) direct child of canonical `/tmp` or the system temporary directory; the CLI fails closed on other platforms. The command never changes Zotero records. +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. + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/ConceptWeave) **Automatic, evidence-bound ontology and semantic-layer engineering for governed enterprise meaning.** diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index a89784c9..3600a494 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -36,6 +36,20 @@ No dedicated utility repository or Zotero mutation path is created. A future Zot ## Consequences +### Private output failure amendment (Proposed, 2026-09-06) + +The checked canonical parent is used to reconstruct the output path, reusing +existing fix `86288cdf5959040a95221c2ca2d99e243d25dc27` as `25154b6` rather than +introducing another path policy. The report is opened exclusively with mode +`0600`, then permissions are enforced on its handle before any report bytes are +serialized. If enforcement fails, `48d7068` returns the error without unlinking +the pathname: it may now refer to an unrelated replacement. An inode comparison +followed by unlink would still race, so that alternative is rejected. The downside +is a possible empty private file requiring later deliberate cleanup; confidentiality +and unrelated-file preservation take precedence over automatic cleanup. RED +`7cfc7fb` demonstrates both raw-parent reuse and replacement deletion. This policy +must also reach the later shared private-output writer before final adoption. + ### 2026-09-05 integrity amendment (Proposed) In the context of replaying a Zotero research classification against a steward's approved labels, facing source fields lost during projection and predictions mutable after review, we decided for separate source-and-input and proposal-content digests verified with the complete reviewed set, and against typed-only source hashing or a report's self-declared cached proposal identity, to preserve the exact evidence used for evaluation, accepting a receipt-format break, report regeneration and fresh governance approval. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a2778345..5aa39db3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -89,6 +89,45 @@ Remaining work: mandatory adoption by restoration, worksheet, duplicate and writ ## DDD fitness constraints +### PR #23 private report path repair (2026-09-06) + +Untouched `2a3619f52e1d3e4f699c91be1fc2d0e9a6e234c8` passed 121 tests/23 suites. +Normal merge `01d6e3f` preserves that delta and PR #22 `51d1682`; integration +passed 145/23. Unix creation-time `0600`, handle-based permission enforcement, +exclusive creation, and non-Unix rejection remain intact. + +Independent review found two existing defects. RED `7cfc7fb` compiled and failed +two of five CLI tests: raw `/tmp` was returned instead of the checked canonical +parent, and permission failure deleted an unrelated replacement at the output +path. Existing canonical owner fix `86288cdf5959040a95221c2ca2d99e243d25dc27` +was reused with provenance as `25154b6`. `48d7068` propagates permission errors +without unlinking a pathname that may have changed. Report serialization never +starts on that failure; an empty private file may remain for deliberate cleanup. +The regression verifies no group/other mode bits before the injected setter and +preserves the replacement sentinel. Only synthetic temporary files were used. + +`c0db7ff` corrects the old raw-system-temp test expectation. Coverage then exposed +the reachable nameless `..` case; `522e46b` reuses the later-owner rejection test +and removes incidental branches from test cleanup, with no coverage exclusions +or weakened runtime checks. Independent review found no further production issue. +The later shared private-output writer still needs this no-unlink failure policy. + +Final 147 tests/23 suites including three doctests, strict Clippy, warnings-denied +rustdoc, format/CI-contract/diff and unchanged coverage pass. Coverage: 278/278 +functions, 2404/2404 normalized regions, 404/404 normalized branches; raw LLVM +3183/3244 lines, 4795/4896 regions, 360/404 branches remain below 100%. +Logs use `/tmp/conceptweave-pr23-private-` with `red.log`, `final.log` (old path +expectation failure), `verified.log`, `clippy-verified.log`, `rustdoc-verified.log` +and `coverage-verified.log`; baseline/integration use `pr23-scope-` instead. +README and Proposed ADR record the empty-file downside and rejected racy cleanup. + +No fresh visual evidence was collected; latest native attempt encountered the +locked Mac. Historical 3,719 displayed items are not reclassification evidence. +Real decisions/approvals remain 0/3,715 plus four unresolved sources; no actual +authorization, mutation, recovery, protected merge or release occurred. Next +verified successor is PR #24 complete review evaluation +`1e73e1545de32ae9a349c469a7794c5c3fc2ae9b`; root and shared-writer adoption remain open. + ### PR #22 steward-context binding verification (2026-09-06) Untouched `7179d13b45d160682e4cce1473c145d465fe657b` passed 120 tests/23 suites.