# 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::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`.