Conventions
The rules inside a crate. cargo xtask tidy enforces the ones marked (tidy); the
allowlists it uses are data at the top of xtask/src/tidy.rs. Keep this file current:
a rule that changes changes here first. The dependency rule between crates is in
ARCHITECTURE.md.
These rules are the content of CONVENTIONS.md. cargo xtask tidy enforces the ones marked (tidy).
Module layout
src/lib.rsis a table of contents: a//!crate doc of at most one screen stating purpose, allowed dependencies and what does not belong here;moddeclarations; onepub useblock of the crate's public surface. No logic inlib.rs.- Children of a module live in a directory named after it:
foo.rsdeclaresmod bar;andfoo/bar.rsholds it (the 2018 layout; nomod.rsfiles, tidy). codex and rust-analyzer use the same layout. - Unit tests live in
foo/tests.rsunder#[cfg(test)] mod tests;at the end offoo.rs, never inline and never as a siblingfoo_tests.rs(tidy rejects*_tests.rsand inlinemod tests {). - One type per file for anything with more than one
implblock or more than one screen of methods; small value types may share a file named for the concept (ids.rs,error.rs). - A pure module inside a crate that is not pure (
efr_tools::patch, listed inPURE_MODULES) names notokio, noasync, no IO of std (std::fs,std::io,std::net,std::process,std::env,std::thread), no workspace crate and nocrate::path outside itself (tidy). It is what a forbidden edge to tokio is for a crate. - Binaries keep
main.rsto argument parsing, tracing setup and a call intorun.rs. Everything in a bin crate ispub(crate). - A crate's README states: purpose, the tier, allowed dependencies, the invariant it protects, how to run its tests without the rest of the workspace.
Tests
- One integration test binary per crate:
tests/it/main.rsdeclares one module per area (tests/it/hello.rs,tests/it/smoke.rs) and holds the#![cfg(test)]line; insta snapshots of those modules live intests/it/snapshots/(tidy). Cargo makes a binary of everytests/*.rs, and each one links the crate and all its dependencies again: eleven files in efr-daemon were eleven binaries of about 320 MB that each held the whole daemon. One binary links once, and nextest still runs every test in a process of its own. A test is named<crate>::it <module>::<test>;cargo nextest run -p efr-daemon -E 'test(/^hello::/)'runs one module. - A bench is an ignored test named
bench_perf_<name>(the search benches keepbench_search_<name>), in the crate'stests/itbinary or, for a module that only the crate can reach, in itstests.rs. It usesefr_test_support::bench: it times a ladder of input sizes, reports theslopewhen the cost depends on a size, and endsReport::finish, which prints the lines and writes them as JSON lines toEFR_BENCH_OUT. A bench never uses a real model or the user's daemon, and it ends on today's code: a size that takes more than 10 s stops its ladder.just bench-perfruns every bench in release, one at a time, andjust bench-compare <old> <new>prints the change between two runs.just bench-sizechecks the release binaries againstxtask/size-budget.json. A fix that makes a path faster gives its bench a budget, so the gain cannot go away unseen. - Debug info is
line-tables-onlyfor the workspace's crates and off for dependencies in dev and test builds (the rootCargo.toml): a backtrace keeps the file and the line of each frame, andtarget/stays a fraction of its size.CARGO_PROFILE_DEV_DEBUG=fullturns full debug info back on for a debugger session.
Naming
| Thing | Rule | Example |
|---|---|---|
| Crate | efr-<context>, kebab-case, a noun | efr-permissions |
| Library | efr_<context> | efr_permissions |
| Binary | short, no prefix repetition | efrd, efr |
| Module | snake_case noun; verbs only for process modules | run.rs, reconcile.rs |
| Wire method | noun.verb on the wire, noun_verb.rs as the file and NounVerb as the params type | conversation.subscribe, methods/conversation_subscribe.rs, ConversationSubscribe |
| Event | Event::<Noun><PastParticiple> | TurnStarted, ApprovalRequested, CwdChanged |
| Error | <Crate>Error enum per crate, variants named for what failed, not where | StoreError::Busy, ProviderError::RateLimited |
| Actor pair | XActor (owns state, runs the loop) and XHandle (Clone + Send) | ScreenActor, ScreenHandle |
| Trait for a boundary | the role, not I or Trait suffix | Screen, PtyHolder, Provider, Tool, SecretStore |
| Feature | kebab-case, one adjective or noun | local-pty, screen-ghostty, keyring, conformance |
| Env var | EFR_ prefix, screaming snake | EFR_LOG, EFR_SCREEN, EFR_DATA_DIR |
| Migration | NNNN_<noun>.sql, four digits, never renumbered | 0001_events.sql |
| Fixture | <case>.ndjson or <case>.json, lowercase, words joined by _ | cwd_move_between_turns.ndjson |
Visibility
unreachable_pub = "warn"(as in rust-analyzer): an item ispubonly if it is re-exported fromlib.rs; otherwisepub(crate).- No glob re-exports (
pub use foo::*) and nopreludemodules; a reader must find a name by followinglib.rs. #[non_exhaustive]on every wire enum inefr-protocoland on every error enum; matches outside the owning crate use a wildcard arm. The one deliberate exception is theMethodenum, matched exhaustively inefr-daemon/src/methods.rsso a new method cannot ship without a scope.- Constructors over public fields for anything with an invariant; plain
pubfields for serde wire structs.
Docs
- Every
pubitem in tier 0 and tier 1 crates has a doc comment (missing_docs = "warn"for those crates, set in each manifest's[lints]override; the rest of the workspace is exempt until it stabilises). rustdoc::broken_intra_doc_links = "deny"; CI runscargo doc --no-depswith-D warnings.- Doc tests are off (
doctest = false) in every crate exceptefr-stdxandefr-protocol; examples elsewhere go inexamples/or tests. ARCHITECTURE.mdfollows the rust-analyzer shape: a map of the crates, the invariants, where to find things, and the threading model.docs/protocol.mdis edited by hand for now:cargo xtask protocol-docsis a stub, and later generates it fromefr-protocolwith theschemafeature (schemars 1.2.2). Each protocol change adds a line to its changelog.docs/protocol.schema.json, the protocol for the phone app's code generator, is generated bycargo xtask protocol-schemaand never edited by hand;just tidyand CI fail when it is not current. The phone app's types,app/src/lib/protocol/generated.ts, are generated from that file bypnpm gen:protocolinapp/and never edited by hand;just app-checkand the CI app job fail when they are not current.- Comments explain why, never what; a
// NOTE:or// SAFETY:prefix marks the two kinds a reviewer must read.
Error types
- Library crates use
thiserror2.0.21 enums;anyhow1.0.104 is allowed only inefr-daemon/src/main.rs,efr-cli,xtaskand tests. A tidy rule rejectsanyhowin any otherCargo.toml. - Each crate has exactly one public error enum in
error.rs. A public module that was a crate of its own (ADR 0014) keeps its own enum in its ownerror.rs:efr_tools::patch::PatchError,efr_providers::openai::OpenAiError,efr_providers::anthropic::AnthropicErrorandefr_transport::client::ClientError. Variants carry structured data (path: PathBuf,seq: Seq), never preformatted strings.#[source]chains are preserved; aDisplaymessage says what failed in one sentence without the source's text. - Wire errors:
efr_protocol::ErrorFrame { id, error: { code: ErrorCode, message, data } }withErrorCodeas a closed set (ProtocolMismatch,Unauthorized,Forbidden,NotFound,Conflict,Busy,Overflow,Cancelled,Invalid,Internal). The onlyFrom<DaemonError> for ErrorFramelives inefr-daemon/src/error.rs; no other crate knows the mapping. unwrap_used,expect_usedandpanicaredenyoutside tests (allow-unwrap-in-tests,allow-expect-in-tests,allow-panic-in-testsinclippy.toml, the codex and nushell setting). The escape hatch is#[expect(clippy::unwrap_used, reason = "...")]on the smallest item. Infallible-by-construction cases usematchwith anunreachable!()arm that states the invariant.- Errors are logged once, at the boundary that drops them: a method handler, an actor loop or
main. Library crates return them.
Logging and tracing
tracing0.1.44 everywhere; thelogcrate is forbidden bydeny.tomlbans.- Spans: one per connection (
conn_id,surface,uid), one per turn (conversation_id,turn_id), one per tool call (tool,call_id), one per provider request (provider,model,request_id). Field names are snake_case and stable because the optional JSON log file is searched with grep. #[tracing::instrument(skip_all, fields(...))]on actor entry points and method handlers; never on hot paths (feed,vt_write, the PTY reader).- Levels:
errorfor a failed user-visible operation,warnfor degraded but continuing (a refresh retry, a dropped slow subscriber),infofor lifecycle (start, ready, shell spawned, login completed),debugfor per-request detail,tracefor bytes (never on by default). - Secrets never reach a field: tokens are
secrecy::SecretString(0.10.3) whoseDebugis redacted, request headers go throughefr_http::redact, and the zsh plugin'sShellContextis logged withoutlast_command. - Subscriber setup is only in
efr-daemon/src/telemetry.rsandefr-cli/src/main.rs:EnvFilterfromEFR_LOG(defaultinfo,efr_=debugundercargo run,infoas a service),tracing-journald0.3.2 whenJOURNAL_STREAMis set, otherwise a compact stderr layer; an optional JSON file layer throughtracing-appender0.2.5 under$XDG_STATE_HOME/efr/logs/(whether it is on by default is undecided).
Config
- One file,
$XDG_CONFIG_HOME/efr/config.toml, owned byefr-config: one schema forefrdandefr, parsed intoSettingswith#[serde(deny_unknown_fields)]on every table (toml1.1.6,serde1.0.229), then checked. A new key is a field of its table there, with its doc comment, its default and, when it needs one, its check; the JSON schema, the key list and the effective dump follow from the types. Precedence: built-in defaults, then the file, thenEFR_*environment variables, then flags (efr-daemon/src/config.rs).efrd --print-configandefr config showprint the effective result with the source of each value. - The daemon keeps its
Settingsin awatchofArc<Settings>; a reader takes the latest value when its unit of work starts (a turn, a prompt, a tool call) and keeps it. Other crates receive plain config structs (ShellConfig,ProviderConfig,StoreConfig) by value, or read through a trait the daemon implements (efr_conversation::ConfigSource). No global config, noonce_cellconfig, no crate reads the environment exceptefr-stdx::envand the daemon. - Paths come from
efr_stdx::paths(etcetera0.11.0 for XDG): config$XDG_CONFIG_HOME/efr/, data$XDG_DATA_HOME/efr/(database, recordings, scratch, secrets, memory later), state$XDG_STATE_HOME/efr/(logs), runtime$XDG_RUNTIME_DIR/efr/(socket,daemon.json), else/run/user/<uid>/efr.EFR_DATA_DIRand friends override each root for tests;EFR_HOMEplaces every root without its own variable below one directory. - The daemon reloads the file live (
efr-daemon/src/reload.rs): a file with an error changes nothing, and a key ofefr_config::RESTART_KEYSkeeps its running value until a restart.docs/config.mdanddocs/config.schema.jsonare generated bycargo xtask config-docsand never edited by hand.
Async and actor rules
- One tokio 1.53.3 runtime per binary, started in
efr-daemon/src/main.rsandefr-cli/src/main.rs:efrdbuilds a multi-thread runtime of at most four workers (efr-daemon/src/runtime.rs),efra current-thread one (efr-cli/src/run.rs). Library crates never create runtimes and never callblock_on; functions that need a runtime areasync fn. - State is owned by actors, not shared: one actor per conversation, one
StoreWriter, oneShellSessions, oneScreenActorper screen. Communication istokio::sync::mpscwith bounded capacity (default 64) andoneshotreplies. A handle type is the only public API of an actor. - No lock is held across an
.await(await_holding_lock = "deny",await_holding_invalid_typefor tokio guards, as in codex).std::sync::Mutexis fine for short critical sections;tokio::sync::Mutexonly when the guard must live across an await, which should be rare and commented. - Time is injected: anything that schedules, retries or expires takes a
Clock(efr_stdx::time::Clock,SystemClockin production,TestClockin tests).tokio::time::sleepappears only insideSystemClock. This is what lets provider refresh timing and lease expiry be tested without waiting. The one exception is the sandbox launcher:efr-sbxmay not reachefr-stdxor tokio, soefr-sbx/src/os.rsis the one place where it reads its environment and real time and starts processes, and its grace loops take that time through a small trait of their own (exit_child::Processes). - Randomness is injected the same way (
Rngtrait,rand0.10.3 behindSystemRng, a seededTestRng). - Blocking work (SQLite reads, file hashing) runs in
spawn_blocking; the only code that is allowed to block a tokio worker is the PTYpre_execclosure. - Backpressure is explicit: every fanout (subscriptions, PTY output) has a bounded queue per consumer, and overflow closes that consumer with
ErrorCode::Overflowcarryinglast_seq, never slows the producer. A lossy item (a draft of a running turn) has a small room of its own in the same queue; a full room drops it and never closes the consumer. cfg(feature = ...)may appear only in named files:efr-daemon/src/screens.rs(screen-ghostty),efr-daemon/src/shells.rs(local-pty),efr-daemon/src/sandbox/seams.rs(test-sandbox-fake),efr-credentials/src/lib.rs(keyring, onemodline),efr-screen/src/lib.rs(conformance, onemodline),efr-protocol/src/lib.rs(schema, onemodline) (tidy).
Unsafe policy
unsafe_code = "deny"in[workspace.lints.rust]. The one module per crate that needs it opts out with#[allow(unsafe_code)]on the module item inlib.rs(main.rsfor a binary) plus a module doc explaining why, andcargo xtask tidyholds the allowlist of files in which the tokenunsafemay appear:crates/efr-pty/src/local_holder.rsat milestone 1,crates/efr-sbx/src/fds.rsfor the sandbox launcher (docs/adr/0007-efr-sbx-unsafe.md),crates/efr-daemon/src/malloc.rsfor glibc'smalloptandmalloc_trimin efrd (docs/adr/0020-malloc-trim.md),crates/efr-fdpass/src/lib.rsfrom milestone 5. Any otherunsafefails CI.unsafe_op_in_unsafe_fn = "deny",clippy::undocumented_unsafe_blocks = "deny": every block has a// SAFETY:comment naming the invariant, following nushell's unsafe policy, including the system behaviour relied on (whatpre_execmay call, which fds are open).- FFI goes through
rustix1.1.5 andnix0.31.3 beforelibc0.2.190; rawlibccalls are allowed only in the allowlisted files. libghostty-vtis consumed through its safe API only;efr-screen-ghosttyhas no unsafe. If a future pin needs a hand-written trampoline for the semantic prompt effect, it goes inefr-screen-ghostty/src/ffi.rs, added to the tidy allowlist in the same commit, following herdr's pattern of local trampolines.