Coding Conventions
On this page
These rules apply to all Rust code. Enforcement notes (clippy lint, xtask check,
code-review-only) are inline; where enforcement is code-review-only, the rule still
applies to every MR and reviewers block on violations. The strict
[workspace.lints] union mechanically enforces the bulk; this page is the prose
the judgment residue, distilled to directives in the coding-conventions rule.
Style — Core Principles
-
No sync/async mixing. Use
tokio::fs/tokio::io::AsyncReadinsideasync fn; wrap blocking calls with no async equivalent intokio::task::spawn_blocking. Brief sync work is OK; astd::sync::Mutexheld across.awaitis a deadlock risk. -
No dead code, no underscore-prefixed unused. Remove unused code instead of silencing with
_var. File an issue instead of leaving "future work" placeholders. -
Prefer libraries over re-implementation. Re-implement only when the library is grossly insufficient or unmaintained (2+ years). Add deps with
cargo add. -
Composition over ease. Break problems and objects into smaller ones; component-level simplicity beats line-count economy.
-
Performance is not the priority. Favor simple, understandable code over optimal performance, as long as it is reasonably performant.
Size / Complexity Ceilings
-
Functions ≤ 40 lines (clippy
too_many_lines,too-many-lines-threshold = 40inclippy.toml). <10% may exceed; each needs a justification
#[allow(clippy::too_many_lines, reason = "…")]. -
Structs / impl blocks ≤ 16 methods (excluding getters/setters/builders). Enforcement:
cargo xtask quality-budgets. -
MR / commit size ≤ 500 LOC changed per increment — a maintainability rule, not a hard CI gate. Split larger work into independently-mergeable batches.
Pre-Implementation Design
For anything non-trivial, before writing code: sketch the types (structs/enums/
traits, field types, error variants), the module boundaries (pub vs
pub(crate) vs private), and the error story (what fails, which variant, how it
propagates). Then write code. For non-trivial work this lives in a plan document;
for trivial changes a paragraph in the issue/MR suffices.
Newtype Pattern
Domain values use newtypes, not raw primitives — argument-transposition becomes a compile error.
// Bad — compiler accepts transposed args
fn create_user(age: u32, id: u32) -> Result<User> { ... }
// Good — compiler rejects transposition
struct UserId(Uuid);
struct Age(u32);
fn create_user(age: Age, id: UserId) -> Result<User> { ... }
Apply to IDs, ages, durations, paths, URLs, secrets (SecretString), monetary
amounts, currencies, typed indices. Skip ephemeral locals and arithmetic where the
primitive IS the concept. Newtypes typically derive Debug, Clone, PartialEq, Eq,
Hash and provide a validating new(…); use #[serde(transparent)] to match the
inner wire format.
Errors
-
No
unwrap/expect/panic!/unimplemented!()/unreachable!()/todo!()in non-test code. All errors/options propagate viaResult/Option.mainis the only legal exit, viaeprintln!+std::process::exit(1). (clippyunwrap_used,expect_used,panic,todo,unimplemented,unreachable.) -
Error types cannot be strings. Enum variants wrap typed inner errors.
-
No
std::io::ErrorKind::Otheras a string-error workaround — define a typed variant. -
No
Box<dyn std::error::Error>returns — concretethiserror::Errorenums. -
No
anyhow::Errorat public API boundaries (internalanyhowis fine where it doesn’t cross apub fn). -
No silent runtime failures. Every
let _ = result/.ok();/.unwrap_or_default()on a diagnostically-meaningfulErrmust propagate via?, log viatracing::warn!, or carry// SILENT-OK: <reason>. (clippylet_underscore_must_use+ignored_unit_patterns.) -
anyhowfor application errors,thiserrorfor library errors. The single-parameterResult<T>form meansanyhow::Result<T>(app) or a crate-local alias (lib) — never a barestd::result::Resultwith an elided error type. -
Server-side: log the real error, return a generic message to the client.
Concurrency Primitives
-
std::sync::Mutexis forbidden in project code. Useparking_lot::Mutexfor short sync sections (no poisoning),tokio::sync::Mutexacross.await. -
No project-authored interior mutability (
RefCell,Cell,Mutexfield for&selfmutation) without an ADR. Third-party interior mutability (DashMap, governor, parking_lot, tokio sync) is pre-approved. -
All public API types must be
Send + Sync(axum + tokio-spawn). Verified at compile time.
Types and Serialization
-
No
serde_json::Valuein business-logic code. Typed structs only; partner/edge carve-outs require// PARTNER-EDGE-UNTYPED: <reason>. -
All functions documented via rustdoc (clippy
missing_docs_in_private_items). Docs are for humans; agents verify behavior by reading the implementation.
Code Organization
-
No hardcoded constants scattered in function bodies — define at file top as
const/static. (code-review-only) -
Lists alphabetically ordered (rustfmt handles
use; struct fields, match arms without ordering constraints, enum variants by convention). (code-review-only)
When You Can’t Comply
If planned work would violate a §Style rule (function size, method count,
no-panic, no-Value, typed-error, etc.), alert the user/parent agent BEFORE
writing the violating code — not after, and not via a silent #[allow(…)].
-
Cite the specific rule and the forcing constraint.
-
Wait for explicit direction. Acceptable outcomes: refactor to comply; an approved
#[allow(clippy::<lint>, reason = "…")]with subagent verification; or a plan scope update. -
Retroactive
#[allow]justification is not a substitute for pre-write alerting. A 41-line function with areasonslipped in after the fact does not meet the carve-out.
Formatting & Linting
-
Always
cargo fmt --allandcargo clippy --all-targets --workspace --locked — -D warnings. -
EditorConfig enforces indentation: 4-space Rust, 2-space TOML/YAML/JSON/CSS/TS/JS/AsciiDoc/HTML.
-
Zero-warnings policy — clippy warnings are CI errors.
Lint Policy (workspace [lints] table)
The template ships a strictest-union [workspace.lints] table in Cargo.toml;
member crates inherit via [lints] workspace = true. The app/library crate adopts
it clean; the xtask tooling crate carries a reason-bearing crate-root carve-out
for CLI/plumbing-inherent lints. Highlights:
-
Groups:
pedantic+cargodeny (NOTnursery— it is unstable; cherry-pick individual nursery lints likecognitive_complexityinstead). -
Panic/silent-failure:
unwrap_used,expect_used,unwrap_in_result,panic,todo,unimplemented,unreachable,let_underscore_must_use,ignored_unit_patterns— deny. -
Index/overflow:
indexing_slicing,string_slice,arithmetic_side_effects— deny. -
IO:
print_stdout,print_stderr— deny (CLI/xtask carve out at crate root). -
Match/struct/async:
wildcard_enum_match_arm,partial_pub_fields,await_holding_lock,await_holding_refcell_ref— deny. -
Complexity/docs:
too_many_lines,cognitive_complexity,missing_docs_in_private_items,allow_attributes_without_reason— deny. -
Rust-level:
unused_must_use— deny;unsafe_code— deny (reason-bearing per-crate#[allow(unsafe_code, reason = "…")]opt-in for a justified FFI/SIMD need).
Static regex exception: Regex::new(r"…")? (propagate) is preferred; where a
LazyLock<Regex> or .expect("static regex") is genuinely needed, justify with
#[allow(clippy::expect_used, reason = "static regex; failure is a programmer bug")].
Library-only lints (not workspace-wide — they over-fire on tooling): add to a
public-API crate’s lib.rs:
#![warn(missing_docs)]
#![warn(unreachable_pub)]
#![warn(unused_crate_dependencies)]
Test carve-out: a #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, …))]
block at each lib root is expected plumbing — not a weakening.
When to override (and when never to)
A lint or synced-file rule can be overridden two ways: a
.claude/sync-overrides.toml entry that downgrades a synced-file drift (e.g. an
edited [workspace.lints] block) to advisory (exit 3), or a reason-bearing
#[allow(lint, reason = "…")] at a single site. Either way an override is
surfaced-and-decided: the agent states the trade (what the lint catches, the
cost to fix, the cost to override) and the user makes the call. It is never
agent-autonomous. Two field failures bound the rule — canopy over-applied
(grandfathered three configs when one warranted it); imtn refused a correct
~700-touch sweep by reaching for the override as an escape hatch. Both are the
same error: the agent deciding silently instead of surfacing the trade.
First-class override territory — a legitimate, user-decided call:
-
Macro-generated code — a lint firing inside a derive/macro expansion the project does not author.
-
Mature/stable grandfather lists — a large, stable, low-churn module where the risk of a mechanical sweep outweighs its value.
-
Project-critical configs that predate the policy — pinned for a documented reason, not silently.
-
Large pedantic-ONLY sweeps where the churn-to-value trade is genuinely poor (hundreds of touches for a purely stylistic lint).
Never override — fix the code for real. The correctness class is not a style nit; an override here hides a defect:
-
indexing_slicing,string_slice— useget(..),strip_prefix, or pattern matching; never a manual index/slice on an untrusted length. -
arithmetic_side_effects—checked_*+?by default (or a justifiedsaturating_*/wrapping_*with a comment), never silenced. -
Prefer
strip_prefixover manual slicing, let-else over partial-match unwrapping, and merged/non-redundant match arms.
An agent may neither silently grandfather a config (the canopy failure) nor
unilaterally refuse correct work by invoking an override (the imtn failure). A
correctness-class lint is never on the table — there is no trade to surface.
Known limitation — coarse granularity. A sync-override is whole-entry.
Overriding cfg-cargo-lints owns the ENTIRE [workspace.lints] managed region,
so silencing ONE lint forfeits future template lint-sync for the whole block —
the Override struct (checkdocs::engine: id / reason / since /
expires) has no per-lint field. A finer-grained per-lint override is a
deferred candidate — do NOT build it as part of this guidance. Until it
exists, prefer a narrowly-scoped reason-bearing #[allow(…, reason = "…")] at the
offending site over overriding the whole lints block when a single lint genuinely
warrants an exception.
Rust Edition & Toolchain
-
Edition 2024; toolchain stable (components: rustfmt, clippy).
-
2024 reserved keywords
genandref— avoid as identifiers. In Askama templates use{% if let Some(x) %}, notSome(ref x).
Documentation Format
-
Project docs: AsciiDoc (
.adoc), rendered by Antora. -
Agent guidance:
.claude/rules/*.md(terse digests) +.claude/CLAUDE.md(thin). -
CHANGELOG.adoc: Keep-a-Changelog, entries under== Unreleased. -
Plans:
.adocunder the project’s plans directory.
Input Validation
-
Validate at the API boundary — never trust client-side validation.
-
Parameterized queries only — no string concatenation/interpolation in SQL.
-
Sanitize user-submitted text (HTML sanitization with a vetted library).
-
Native HTML5
requiredattributes are defense in depth, not sole validation.
UUID v7
Use UUID v7 for all primary keys (uuid::Uuid) — time-ordered, sortable, globally
unique without a separate timestamp column.
HTTP / API Conventions
-
All API calls idempotent.
-
Create endpoints return 200 (Axum
Json<T>default), NOT 201. -
Cross-service HTTP: a shared
reqwest::ClientviaArc<Client>, neverClient::new()per request. -
All HTTP APIs use RFC 9457 Problem Details for error responses.
API Contract Stability
-
Pre-1.0: breaking changes permitted but documented in
CHANGELOG.adocunderChanged/Removed. -
Post-1.0: response shapes are additive only — no field removals, type changes, or renamed endpoints.
Dependency Management
-
Latest stable versions; pin to non-latest only with a commented reason in
Cargo.toml. -
Workspace-level
[workspace.dependencies]. Alwayscargo add(gets latest). -
cargo audit(CI, blocking),cargo deny(license allowlist — AGPL-compatible, duplicate detection, advisory DB),cargo machete(CI, blocking — unused deps). -
Monthly review:
cargo update+ full test verification. Maintain a banned-crates list indeny.toml.
Database Migrations
-
Format
YYYYMMDDHHMMSS_descriptive_name.sql. Additive only — renames/drops via a two-step deprecate-then-remove. Check existing timestamps to avoid collisions. -
Database name must match the service name (
{project}_{service}); validate at startup and refuse to start on mismatch.
Container Runtime
-
Alpine is mandatory for all images (build + runtime). Build
rust:alpine(musl, latest stable, pinned per-project); runtimealpine:<version>(pinned). -
Every image: non-root user,
HEALTHCHECK(services), multi-stage build, a.dockerignoreexcludingtarget/,.git/,node_modules/. -
musl ⇒
rustls, notopenssl(theopensslcrate is banned indeny.toml). A glibc-only dep with no pure-Rust alternative needs an ADR.
CI/CD Runners
-
Use the org self-hosted runners — not GitLab shared (
saas-linux-). The pool is defined once asRUNNER_SMALL/RUNNER_MEDIUM/RUNNER_LARGEvariables:in.gitlab-ci.yml(GADHSdhs-aws-autoscaler-docker.defaults — a PROJECT setting; override the three variables to retarget the pipeline). -
Every job has an explicit
tags:(one of those variables) — never inherit a default. -
Sizes: small = lint/audit/doc/hash jobs; medium = fmt+clippy+nextest, release builds, cross-compilation; large = Docker-in-Docker, E2E suites, corpus tests. Choose the smallest runner that finishes in reasonable time.
Task Runner
-
cargo xtaskis the mandatory task runner for all automation. No shell scripts (.sh/.ps1/.bat). -
xtask/is a workspace member withname = "xtask". Standard subcommands:dev test e2e validate check-docs coverage mutants quality-budgets plan-lint secrets-yaml-lint audit-memory fn-shape-report. Add project-specific ones (seed,migrate,codegen,perf) as needed. -
For non-developers without Rust: pre-built xtask binaries ship as GitLab Release artifacts.
Configuration
Environment Variable Naming
-
Convention
{PROJECT}_{SERVICE}{SETTING}(double underscore separates service from setting), e.g.CRAIG_RULESPORT,CANOPY_PERSONS__DATABASE_URL. -
Infrastructure variables (shared):
{PROJECT}_SEED,{PROJECT}_ENV. -
Double underscore enables automatic struct-field mapping (e.g.
config-rs). -
Document every setting in
.env.example.
Settings Struct Pattern
-
Load settings from env via a typed
ServiceSettingsstruct. -
Debugimpl must redact secrets (database_url,rabbitmq_url,*_key,*_secret,*_password). Usesecrecy::SecretStringfor never-print fields. -
Validate required fields at startup — refuse to start on missing config, never silently default.
Recommended Service Patterns
Recommended for service projects (CLI tools and libraries can skip):
-
Rate limiting —
governorper-IP on public endpoints; configurable via{PROJECT}_{SERVICE}__RATE_LIMIT_RPM(0 disables); behind a proxy, parse the real IP fromx-forwarded-foronce per request against a trusted-proxy list. -
Circuit breaker — for inter-service HTTP; trip after N consecutive failures, return graceful degradation, attempt one request after cooldown.
-
Idempotency-Key middleware — for side-effecting POST/PUT; cache
{method}:{path}:{user_id}:{key}24h; return the cached response withx-idempotency-replay: trueon duplicates. -
OpenTelemetry propagation — extract
traceparent/tracestatefrom incoming requests, inject into outbound;opentelemetry+tracing-opentelemetry. -
Prometheus metrics —
/metricsalongside/healthz; request duration histogram, count by status, error rate. -
Persistent event outbox — for messaging projects, store events in the caller’s DB transaction; a background drainer publishes and marks sent (survives broker outages). Schema
(id, aggregate_id, event_type, payload, created_at, published_at). -
Authz coverage warning — for policy-engine projects, compute coverage of
ResourceType × Jurisdictionat boot; warn (or hard-bail with…__AUTHZ_REQUIRE_FULL_COVERAGE=true) on gaps.
Plan Authoring
-
All plans are
.adocunder the project’s docs directory, linked innav.adoc— Step 1 of every plan, BEFORE implementation..claude/plans/is ephemeral scratch only. -
The
nav.adoclink convention (Active/Planned/Deferred/Archive) applies to a project’s own plans. A repo MAY keep internal/meta plans repo-only — flat in the plans dir, not nav-linked, not published on the docs site — when they are about building the tooling itself rather than the product.cargo xtask initclears such template meta-plans from a fresh downstream scaffold, so a new project starts with an empty plans dir (and nav-links only its own plans). -
Never assume a plan is pending from a scratch file — verify against GitLab + git history.
-
Plans must be detailed enough to implement without further context: exact file paths, struct/function names, code patterns, inputs/outputs, error cases.
-
A plan presented for review MUST include every required element first:
.adoccreated + linked, documentation step, verification/testing step, GitLab issue/branch details.
Plan Lifecycle
-
Plans are living specs, not immutable records. When implementation deviates, update the plan’s Design/Scope — the plan↔code diff is zero, not "documented in errata".
-
Errata are for genuine post-hoc corrections, not "I built it differently".
-
Found an improvement mid-execution? File a GitLab issue and link it — never a plan "Potential Improvements" section.
-
On completion: Status → done, move the nav entry to archive, link the MR(s). On deferral: Status → deferred with reason.
nav.adocplan sections always reflect reality (Active / Planned / Deferred / Archive).
Canonical Status Vocabulary
For Status cells in plan bodies (case-insensitive first-token match):
| Token | Meaning |
|---|---|
|
Default for new rows |
|
Actively worked in an open MR |
|
Shipped; date + freeform detail; optional |
|
Explicitly descoped; reason required |
|
Cannot proceed; blocker required |
|
Structural row that doesn’t apply |
Anything else (bare "Complete", "✓", "done") is a lint violation. cargo xtask
plan-lint enforces this.
Pre-Push Hook
-
Activate:
git config core.hooksPath .githooks && chmod +x .githooks/*. -
The sole functional-correctness gate; the full battery + CI split are in testing (single source of truth).
-
Never bypass with
git push --no-verify. If a hook needs changing, change the hook.
Known Agent Biases
AI agents trend toward older, heavily-documented tools over newer, better alternatives. When recommending, web-research the current state of the art (see delivery protocol). Stale defaults to watch for:
-
OpenSSL over rustls (rustls is mandated).
-
Selenium/Cypress over Playwright (Playwright mandated for web UI).
-
reqwest+openssl-sysoverreqwest+rustls-tls. -
chronooverjiff/time(evaluate current state). -
Heavyweight ORMs over lightweight query builders (evaluate).
-
Inheritance-heavy patterns over composition and traits.
-
Assuming library APIs from training data instead of reading current docs.
-
Deprecated config formats (e.g. cargo-deny v1 when v2 is current).
-
Jumping to workarounds instead of diagnosing root causes.
-
Defending wrong mental models against contradicting evidence.
-
std::sync::Mutexinstead ofparking_lot/tokiomutexes. -
Box<dyn Error>instead of concretethiserrorenums. -
serde_json::Valueinstead of typed structs "just this once". -
Silencing unused variables with
_varinstead of removing dead code.
This list is a living document — add outdated recommendations you catch.
ADR Conventions
-
Location:
docs/adrs/(AsciiDoc); projects with a generated docs site may relocate them into that tree. -
Write an ADR when choosing a framework, database, protocol, or design pattern with viable alternatives.
-
Format: Status, Context, Decision, Alternatives Considered, Consequences (see
docs/adrs/adr-000-template.adoc). -
ADRs are immutable once accepted — supersede with a new ADR, do not edit.
Project-specific conventions (framework patterns, database, styling, auth, accessibility) live in the project’s own project conventions page, not in this universal standard.