Skip to content

3B2: native filesystem atomic adapter for the curation store - #139

Merged
PhysShell merged 4 commits into
mainfrom
claude/curation-store-3b2-native-fs
Jul 19, 2026
Merged

3B2: native filesystem atomic adapter for the curation store#139
PhysShell merged 4 commits into
mainfrom
claude/curation-store-3b2-native-fs

Conversation

@PhysShell

Copy link
Copy Markdown
Owner

What

The native filesystem backend for the curation store — step 3B2. The
domain model (3B1, griff-core::curation_store) owns the wire format and does
zero I/O; this adapter reads and writes those bytes on a real filesystem,
durably and atomically, so a decision file survives a crash between write()
and the rename that publishes it.

New module cli/src/curation_fs.rs in griff-clinot in core, so
std::fs never enters the domain model.

API

  • load_store(&Path) -> Result<StoreOnDisk, CurationFsError>StoreOnDisk is
    Missing (a typed absence the caller resolves into a fresh empty store) or
    Loaded(store). A missing file is never conflated with a malformed one.
  • write_store(&Path, &CurationStoreV1) -> Result<(), CurationFsError>
    encode_store (which validates) then an atomic write.

The write is temp-then-rename: write a sibling temp → write_all + flush +
sync_all → rename over the canonical path (atomic replace on POSIX and Windows
MoveFileExW) → fsync the parent directory where the platform supports it.
Any failure before the rename removes the temp and returns a typed error,
leaving the previous store byte-identical.

Contract (all 10)

  1. missing file → Missing, not malformed ✅
  2. malformed file → typed Decode refusal, never empty ✅
  3. a written store survives a reload (restart stand-in) ✅
  4. injected failure before replace → old store byte-identical, temp removed ✅
  5. injected failure during replace → typed error, old store intact ✅
  6. the temp never becomes the canonical store ✅
  7. re-writing the same store → byte-identical output ✅
  8. parent-dir fsync where the platform supports it (unix; no-op on Windows,
    where std cannot fsync a directory handle) ✅
  9. no platform-specific detail in curation_store.rs
  10. no OPFS, no cockpit wiring ✅

Failure injection is a private test-only FailPoint seam; the public API has no
injection surface.

Core change (error plumbing only)

StoreValidationError / StoreDecodeError / StoreEncodeError now derive
thiserror::Error so the adapter can wrap them as #[source]s. No domain-model
behavior changes.

Validation

  • cargo test -p griff-cli --lib — 8/8 (7 curation_fs on Windows; the unix
    parent-dir-fsync test runs on CI).
  • cargo test -p griff-core --lib — 275/275 (thiserror plumbing changes nothing).
  • cargo clippy --workspace --all-targets — clean.
  • cargo fmt --check — clean.
  • Pre-existing local-only griff-cli::missing_file_golden (English OS string on
    a RU-locale box) is unrelated.

3C (OPFS + cockpit flow) remains frozen.

🤖 Generated with Claude Code

PhysShell and others added 2 commits July 18, 2026 23:53
…ore (3B2)

The 3B2 contract as failing tests against stub adapter bodies:

1. a missing store file is StoreOnDisk::Missing, an explicit typed absence, not
   a malformed error;
2. an existing malformed store is a typed CurationFsError::Decode, never empty;
3. a written store survives a reload (restart stand-in);
4. an injected failure before the replace leaves the old store byte-identical
   and removes the temp;
5. an injected failure during the replace is a typed error and keeps the old
   store;
6. the temp sibling never becomes the canonical store;
7. re-writing the same canonical store yields byte-identical output;
8. (unix) a write fsyncs the parent directory.

The adapter lives in griff-cli, not core — std::fs stays out of the domain
model. Core's only change is error plumbing: StoreValidationError /
StoreDecodeError / StoreEncodeError now derive thiserror::Error so the adapter
can wrap them as sources. load_store / write_store / atomic_write are stubs, so
the seven behavioral tests fail; the missing-file test passes against the stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkqJUU6d1sW1RAfvyHrqVM
…store (3B2)

Implement the adapter so all seven behavioral contract tests pass (the eighth,
the parent-dir fsync, runs on unix CI):

- load_store: fs::read then decode_store; a NotFound is StoreOnDisk::Missing,
  any other read error is Io, malformed bytes are a typed Decode refusal.
- write_store: encode_store (which validates) then an atomic write.
- atomic_write: write a sibling temp, write_all + flush + sync_all, then rename
  it over the canonical path (atomic replace on both POSIX and Windows), then
  fsync the parent directory where the platform supports it. Any failure before
  the rename removes the temp and returns a typed error, leaving the previous
  store byte-identical; the temp only becomes the store via a completed rename.

Parent-directory fsync is unix-only (std cannot fsync a directory handle on
Windows, where the rename is the platform's durability barrier) — all such
platform detail lives here, none in core.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkqJUU6d1sW1RAfvyHrqVM
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PhysShell, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 77b01ed2-fc85-4d27-94ab-d246eea35c09

📥 Commits

Reviewing files that changed from the base of the PR and between d506c82 and 2f3d3d5.

📒 Files selected for processing (4)
  • cli/Cargo.toml
  • cli/src/curation_fs.rs
  • cli/src/lib.rs
  • core/src/curation_store.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/curation-store-3b2-native-fs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc370e113a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cli/Cargo.toml
griff-swang = { path = "../swang" }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Commit the lockfile edge for thiserror

This adds a normal dependency, but the committed Cargo.lock was not updated; I confirmed cargo check -p griff-cli --locked now fails with cannot update the lock file. Any consumer or CI path that builds with --locked will fail even though the manifest is otherwise valid, so the lockfile update needs to be committed with this change.

Useful? React with 👍 / 👎.

Comment thread cli/src/curation_fs.rs Outdated
Comment on lines +145 to +146
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
File::open(dir)?.sync_all()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fsync . for bare relative store paths

When the caller passes a bare relative path like Path::new("curation.json"), Path::parent() is Some(""), so this filter skips the directory sync entirely. On Unix that means the common current-directory write path returns success without making the rename crash-durable, despite the adapter's durability contract; treat an empty parent as . before opening it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Arbiter review: HOLD — three narrow corrective items

The adapter boundary and the core error-plumbing change are correct. CI is green on dc370e1. Windows replacement via current std::fs::rename is also acceptable; no custom Win32 call is required.

1. Post-rename sync failure is currently misclassified

atomic_write publishes the new canonical file with fs::rename, then calls sync_parent_dir(path). If that final sync fails, the function returns the same CurationFsError::Io used for pre-commit failures, even though the new store is already visible at the canonical path.

That contradicts the public error contract saying an I/O error leaves the previous store intact. More importantly, the caller cannot distinguish:

  • not committed: old store remains;
  • committed, durability uncertain: new store is installed, but the directory entry was not confirmed durable.

Add a typed variant such as:

PostCommitDurability { source: io::Error }

(or an equivalent name), returned only after a successful rename when parent sync fails. Add an injected failure after rename / at directory sync proving:

  • the result is the post-commit variant;
  • the canonical file contains the new bytes;
  • the temp is gone;
  • it is distinguishable from pre-commit Io.

Do not attempt rollback after rename; that would create a second, less reliable transaction.

2. Relative paths skip Unix parent-directory fsync

For Path::new("curation.json"), parent() is the empty path. The current filter drops it, so a common relative store path silently skips directory sync.

Resolve an absent/empty parent to Path::new("."), preferably through a small pure helper, and pin it with a test. Absolute/nested paths should keep their actual parent.

3. DuringReplace does not exercise the rename-error branch

The current failpoint returns before fs::rename, so the “during replace” test is observationally another before-replace test. The real fs::rename(...)->Err cleanup branch remains untested.

Move injection into a private replace seam, for example replace_file(tmp, path, fail), and have atomic_write handle its returned error. Then the test reaches the same branch an OS replace failure uses and proves old bytes + temp cleanup there.

Documentation correction

Keep the platform claim precise: atomic replacement on supported filesystems; file contents are synced before publish; parent-entry durability is explicitly synced on Unix. On Windows this std-only adapter performs no directory-handle sync, so do not describe that final durability guarantee as equivalent to Unix.

After these three changes and green CI, #139 is mergeable. 3C remains frozen.

PhysShell and others added 2 commits July 19, 2026 03:54
…eplace seam (3B2)

Corrective for the PR #139 review. Three narrow gaps, as failing/added tests:

1. A directory-fsync failure AFTER a successful rename is currently reported as
   a pre-commit CurationFsError::Io, whose contract promises the old store
   survived — but the new bytes are already published. New variant
   PostCommitDurability { source } and a FailPoint::AfterReplace; the test wants
   that typed error, the new bytes on disk, the temp gone, and the error
   distinguishable from a pre-commit Io.

2. A bare relative store path (curation.json) has an empty parent, which the
   sync path filtered out, so the Unix directory fsync never ran. New
   parent_for_sync maps an empty parent to "."; the test pins
   curation.json -> ., dir/curation.json -> dir, /dir/curation.json -> /dir.

Both new items are wired as scaffolding with deliberately-wrong bodies
(AfterReplace falls through to a normal Ok; parent_for_sync keeps the empty
parent), so the two tests fail. GREEN adds the post-commit path, the real
replace_file seam (so FailPoint::DuringReplace exercises the actual
fs::rename error branch, corrective #3), and fixes parent_for_sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkqJUU6d1sW1RAfvyHrqVM
…replace seam (3B2)

Resolve the three PR #139 correctives; all 9 curation_fs tests pass.

1. A directory-fsync failure after a successful rename is now
   CurationFsError::PostCommitDurability, distinct from a pre-commit Io: the new
   bytes are already published, there is no old store to fall back to, and no
   rollback — a second file transaction cannot undo the first. atomic_write
   splits pre-commit (Io, old store intact) from post-commit (durability
   warning) explicitly.

2. sync_parent_dir now derives its directory from parent_for_sync, which maps a
   bare relative filename's empty parent to ".", so a relative store path still
   fsyncs a real directory on Unix.

3. The atomic replace is extracted into replace_file(temp, canonical, fail), so
   FailPoint::DuringReplace returns its error from that seam and atomic_write
   handles it through the exact same branch as a real fs::rename failure — the
   OS-replace error path is now covered, not shadowed by an earlier early return.

Docs narrow the durability guarantee: temp contents fsynced before publish;
atomic replace on a supporting fs; directory entry fsynced on Unix; on Windows
this std-only adapter does not fsync the directory handle, so the post-power-loss
guarantee is not identical to Unix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkqJUU6d1sW1RAfvyHrqVM
@PhysShell

Copy link
Copy Markdown
Owner Author

Corrective for the three gaps (RED 751e35f → GREEN 2f3d3d5)

1. Post-commit durability error

atomic_write now splits pre-commit from post-commit explicitly. A directory-fsync failure after a successful rename is a new typed CurationFsError::PostCommitDurability { source } — the new bytes are already the store, there is no old store to fall back to, and there is no rollback (a second file transaction can't undo the first). It is distinguishable from a pre-commit Io, whose contract (old store intact, temp removed) still holds.

New FailPoint::AfterReplace drives it. Test asserts: the PostCommitDurability error, the new bytes on disk, the temp gone, and !matches!(Err(Io)).

2. Relative parent → .

New parent_for_sync maps a bare relative filename's empty parent to ".", so sync_parent_dir fsyncs a real directory on Unix even for a relative store path. Pure test pins curation.json → ., dir/curation.json → dir, /dir/curation.json → /dir.

3. Real replace-error seam

The atomic publish is extracted into replace_file(temp, canonical, fail). FailPoint::DuringReplace now returns its error from that seam, and atomic_write handles it through the exact same branch as a real fs::rename failure — so the OS-replace error path is genuinely covered, not shadowed by an earlier early-return. The existing during-replace test (typed Io, old store intact, temp removed) now exercises that branch for real.

Docs

The durability guarantee is narrowed to exactly what holds: temp contents fsynced before publish; atomic replace on a supporting filesystem; directory entry fsynced on Unix; on Windows this std-only adapter does not fsync the directory handle (std exposes no way), so the post-power-loss guarantee is not identical to Unix. write_store's # Errors now separates pre-commit Io from PostCommitDurability.

Validation

  • cargo test -p griff-cli --lib — 9/9 (curation_fs; the unix parent-dir-fsync test runs on CI).
  • cargo clippy --workspace --all-targets + cargo fmt --check — clean.
  • Pre-existing local-only griff-cli::missing_file_golden (RU-locale OS string) unrelated.

Still HOLD — not merging.

@PhysShell
PhysShell merged commit 1f2550d into main Jul 19, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant