powercut 0.1.1

Prove an acknowledged write survives a power cut or process crash, in cargo test. A database-agnostic crash-injection harness that derives the durable set from the fsyncs that actually happened.
Documentation
# AGENTS.md — everything an LLM needs to use `powercut` correctly, in one read

You are reading this because you are writing durability tests for somebody's
database or storage layer. This file is written for you. It is complete: you
should not need to read the source to get a correct test, and if you did, that
is a documentation bug — please open an issue saying so.

---

## 1. What `powercut` does, in three sentences

It runs the user's workload to the point where their code says *"committed"*,
takes a copy of the store's files as they would survive a chosen kind of
failure, reboots a fresh instance of the store from **only that copy**, and
runs the user's read-back check against it.

The set of files that survives a power cut is **derived from the `fsync` calls
that actually happened**, not from a list anyone wrote down. Delete an `fsync`
from the write path and a file drops out of the durable set.

It has zero dependencies, no `unsafe`, and works with FFI engines.

## 2. The two tiers

| `Tier` | models | keeps | a store that never calls `fsync` |
|---|---|---|---|
| `Tier::ProcessCrash` | `SIGKILL`, panic, OOM kill, OS crash | every byte the app wrote to the filesystem | **passes** |
| `Tier::PowerCut` | the plug coming out | only bytes a successful `fsync` certified, as of that `fsync` | fails |

`Tier::PowerCut` is the default and is almost always what the user means.
Tier 1 reads through the OS page cache, so it can only prove *"the write
reached the file"*.

## 3. The four verdicts

```
Verdict::Survived       the durable set honoured the ack
Verdict::Lost           it did not, and coverage was good enough for that to mean something
Verdict::NotMeasured    the check ran but the result is not sound.  NOT A PASS.
Verdict::HarnessBroken  powercut itself is the suspect; this run accuses nobody
```

**Never write a test that treats `NotMeasured` as success.** `Verdict::is_pass()`
returns `true` only for `Survived`, and `Report::assert_survived()` panics on
`NotMeasured`. If you find yourself writing `!= Verdict::Lost`, stop — that is
the bug this crate's type system exists to prevent.

## 4. The exact minimal code to test a store

Copy this and replace the five marked parts. The `...` lines are placeholders,
not decoration: step (1) must actually create the store's files, because
`sync_path` on a file that does not exist yet is an error, and the whole point
of the setup phase is that this state is durable before the measurement starts.

```rust
use powercut::{Checker, ScratchDir, Scenario, SyncJournal, Tier};

#[test]
fn acked_write_survives_a_power_cut() {
    let store = ScratchDir::new("my-store").unwrap();     // or the user's own dir
    let dir = store.path().to_path_buf();
    let journal = SyncJournal::complete();                // see §5 before claiming this

    // (1) SETUP — anything that must exist before the measured write, made
    //     durable here so a red reads "the write was lost", not "there was no
    //     database". Skip this only if your store has no schema step.
    //     REPLACE the next line: it must really create `app.db` on disk, or the
    //     `sync_path` below fails with NotFound.
    std::fs::write(dir.join("app.db"), b"...create tables / open the store...").unwrap();
    journal.sync_path(dir.join("app.db")).unwrap();
    journal.sync_dir(&dir).unwrap();

    let report = Scenario::new(&dir, journal.clone())
        .label("myengine/default")                        // (2) stable case name
        .tier(Tier::PowerCut)
        .ack(|| {
            // (3) THE WORKLOAD. Its return value IS the ack — return what you
            //     told the caller was committed.
            // ... insert the row / put the key ...
            42_i64
        })
        .unwrap()
        // (4) THE TEETH CONTROL. Not optional for a test you expect to be red.
        //     `flush` must be whatever "really persist it" means for YOUR
        //     store, and it must record its syncs into the same journal.
        .with_teeth_control(|| { journal.sync_path(dir.join("app.db"))?; Ok(()) })
        .unwrap()
        .check(|crash, acked| {
            // (5) REBOOT from crash.dir() and read back.
            let mut c = Checker::new();
            c.baseline("the schema exists", true, schema_exists(crash.dir()));
            c.survives("row 42", Some(*acked), read_row(crash.dir()));
            c.finish()
        });

    report.assert_survived();
}
# fn schema_exists(_: &std::path::Path) -> bool { true }
# fn read_row(_: &std::path::Path) -> Option<i64> { Some(42) }
```

## 5. Choosing `Coverage` — the decision you must not get wrong

`SyncJournal::complete()` claims **every** sync touching the store passes
through the journal. Claim it only when one of these is structurally true:

- the store is code you wrote and all its syncs go through the journal;
- the engine exposes a storage-backend trait you wrapped (redb's
  `StorageBackend`, for example) — see `examples/redb_backend.rs`;
- **the engine has been configured to perform no syncs of its own** (SQLite's
  `PRAGMA synchronous=OFF`) and your code does all of them at the ack — see
  `examples/sqlite_durable_ack.rs`.

Otherwise use `SyncJournal::partial("<what you cannot see>")`. It behaves
asymmetrically and correctly:

- a **passing** check under partial coverage is still sound (the durable set
  was a subset of the truth);
- a **failing** check becomes `NotMeasured`, never `Lost`, because an
  unobserved `fsync` might have saved the data.

If you are testing an FFI engine at its default settings (`libsqlite3`,
DuckDB, RocksDB, LMDB) and you have not turned its own syncing off, **you must
use `partial`**. `powercut` 0.1 has no syscall-interposition backend. Claiming
`complete()` there is how this crate would start libelling databases.

## 6. Common mistakes, in the order agents make them

1. **Treating `NotMeasured` as a pass.** It is not one. See §3.
2. **Claiming `complete()` coverage for an FFI engine at default settings.**
   See §5.
3. **Opening the store *inside* the `ack` closure.** The handle then drops at
   the end of the closure, and for many engines *closing the last handle is
   itself a checkpoint* that rewrites files nobody syncs afterwards. Open it
   outside; that is also where a serving process actually acks.
4. **Forgetting the setup phase.** If the schema was never made durable, every
   run comes back `HarnessBroken` with a baseline violation — which is correct,
   but it means you measured nothing. Make setup durable first.
5. **Omitting `with_teeth_control` on a test you expect to be red.** Without
   it a red cannot be told apart from a wrong path or a broken checker. The
   report says so, in those words.
6. **Writing an `fsync`-shaped teeth control for a store that buffers in user
   space.** No `fsync` makes user-space state durable. The flush must be the
   store's own "really persist it" operation (redb: an `Immediate` commit).
7. **Forgetting `journal.sync_dir(&dir)`.** On Unix a newly created file's
   *name* is only durable after its parent directory is fsynced. `powercut`
   prints a note when you skip it; do not ignore the note.
8. **Listing a durable set by hand.** There is no API for that, deliberately.
   The set is derived. If you want a file in it, `fsync` it.
9. **Using `assert_conforms`-style all-must-pass assertions across engines.**
   Engines legitimately differ. Use `powercut::assert_no_worse_than(&reports,
   "path/to/baseline.tsv")` and commit the baseline.
10. **Putting `POWERCUT_UPDATE_BASELINE` into a CI config.** Recording a
    baseline accepts today's run as correct, so in CI it would retire the gate
    silently. `powercut` refuses it when `CI` is set — it warns and asserts
    against the committed baseline instead. Never suggest it as a way to make
    a red build green; the red is the product working.

## 7. Public API surface, complete

```
Scenario::new(store_dir, witness) -> Scenario
  .label(&str) .tier(Tier) .ack(FnOnce() -> T) -> Result<Acked<T, W>>
  .selected_tier() .selected_label()

Acked::ack() .crash_state()
  .with_teeth_control(FnOnce() -> io::Result<()>) -> Result<Acked>
  .check(Fn(&CrashState, &T) -> Vec<Violation>) -> Report

Report::verdict() .finding() .label() .tier() .coverage() .manifest()
  .reached_filesystem() -> Option<bool>      // present at tier 1 but not tier 2?
  .to_text() .to_markdown_row() .assert_survived()

Tier::{ProcessCrash, PowerCut}          .as_str() .models()
Verdict::{Survived, NotMeasured, Lost, HarnessBroken}   .is_pass() .severity() .as_str() .parse()
Finding::{Survived, Lost, NotMeasured, HarnessBroken}   .verdict() .violations()
Violation::{lost, divergent, baseline}  ViolationKind::{LostData, Divergent, Baseline}
Checker::{new, baseline, survives, push, finish}        IsAbsent

SyncJournal::{complete, partial, with_coverage}
  .sync_all(&File, path) .sync_data(&File, path) .sync_path(path)
  .sync_path_if_present(path) .sync_dir(dir) .record(path, SyncKind, Option<u64>)
  .assume(path, reason)
DurabilityWitness { synced(), coverage(), assumed() }
Coverage::{Complete, Partial{unobserved}}   SyncKind::{File, Data, Directory}
SyncRecord { path, kind, at, durable_len, snapshot }    Barrier   AssumedDurable

CrashState::{capture, dir, tier, manifest, notes, used_assumption, manifest_text}
CapturedFile { path, bytes, warrant, truncated_from }   Warrant::{Synced, OnFilesystem, Assumed}

powercut::{to_markdown, assert_all_survived, assert_no_worse_than, compare_to_baseline}
Baseline::{new, parse, render, load, get, is_empty}     BaselineEntry
UPDATE_ENV = "POWERCUT_UPDATE_BASELINE"   // records a baseline; IGNORED when `CI` is set
ScratchDir::{new, path, keep}
Error::{StoreMissing, Locked, Io}       // #[non_exhaustive]: match with a `_` arm
```

Every item above carries its own compiling doctest on
[docs.rs](https://docs.rs/powercut); if you need to see one used in context,
that is the fastest place to look.

## 8. What `powercut` does not do — do not claim otherwise for the user

- It is **not** a filesystem crash-consistency tester (see CrashMonkey/ACE).
- It is **not** a block-layer fault injector; LazyFS is stronger at that layer
  and Linux-only, and `powercut` trades that power for running in `cargo test`.
- It **cannot** detect a drive that lies about honouring `F_FULLFSYNC`. The
  guarantee bottoms out there and no user-space tool can do better.
- It **does not** observe syscalls. There is no `LD_PRELOAD` backend in 0.1.
- It **models a deleted file as durably deleted**, and does not resurrect a
  file that was fsynced and later unlinked. See §9.
- On **Windows** it **cannot capture a store whose engine holds an exclusive
  whole-file lock** (redb does; SQLite does not, because its locks sit beyond
  EOF). Live capture is the whole design, so a locked range is simply
  unreadable. `CrashState::capture` returns `Error::Locked` in that case — it
  does not skip the file, because a store file missing from a capture would
  manufacture a false `Lost`. The error message says all of this itself; tell
  the user to measure such engines on macOS/Linux, or to arrange for no handle
  to be open at the ack.

## 9. Modelling assumptions you should state if the user asks

- **Deletion is modelled as durable.** If the store fsyncs a file and later
  unlinks it, `powercut` treats the file as gone. Whether the unlink itself
  survives a real power cut depends on whether the directory was synced. The
  practical consequence is the same either way — data that moved out of a
  certified file into an uncertified one is no longer covered by anything —
  but be precise if the user pushes on it.
- **In-place overwrites after a sync are not credited.** `SyncJournal` snapshots
  each file's certified content at each sync, so tier 2 captures the bytes the
  `fsync` actually certified. A hand-written `DurabilityWitness` that supplies
  no snapshot falls back to the live bytes truncated to the certified length,
  which is optimistic — the capture prints a note saying so.
- **`sync_data` (`fdatasync`) may not make a file's length durable.** The kind
  is recorded so a report can show which one was relied on.

## 10. The durability primitive

`std::fs::File::sync_all` dispatches to `fcntl(F_FULLFSYNC)` on Apple targets
(a real device-cache flush), `fsync(2)` on other Unix, and `FlushFileBuffers`
on Windows; `sync_data` uses `fdatasync` on Linux. **You do not need `libc` or
`unsafe` to get a real flush on macOS** — the standard library has done this
for years, and older advice saying otherwise is wrong. `powercut` uses only
these std calls, which is why it can `#![forbid(unsafe_code)]`.

---

If any of the above is unclear, wrong, or missing what you needed: that is
worth an issue, and it will be read as a bug report about this file, not as a
complaint. See `CONTRIBUTING.md`.