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::AsyncRead inside async fn; wrap blocking calls with no async equivalent in tokio::task::spawn_blocking. Brief sync work is OK; a std::sync::Mutex held across .await is 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 = 40 in clippy.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 via Result/Option. main is the only legal exit, via eprintln! + std::process::exit(1). (clippy unwrap_used, expect_used, panic, todo, unimplemented, unreachable.)

  • Error types cannot be strings. Enum variants wrap typed inner errors.

  • No std::io::ErrorKind::Other as a string-error workaround — define a typed variant.

  • No Box<dyn std::error::Error> returns — concrete thiserror::Error enums.

  • No anyhow::Error at public API boundaries (internal anyhow is fine where it doesn’t cross a pub fn).

  • No silent runtime failures. Every let _ = result / .ok(); / .unwrap_or_default() on a diagnostically-meaningful Err must propagate via ?, log via tracing::warn!, or carry // SILENT-OK: <reason>. (clippy let_underscore_must_use + ignored_unit_patterns.)

  • anyhow for application errors, thiserror for library errors. The single-parameter Result<T> form means anyhow::Result<T> (app) or a crate-local alias (lib) — never a bare std::result::Result with an elided error type.

  • Server-side: log the real error, return a generic message to the client.

Concurrency Primitives

  • std::sync::Mutex is forbidden in project code. Use parking_lot::Mutex for short sync sections (no poisoning), tokio::sync::Mutex across .await.

  • No project-authored interior mutability (RefCell, Cell, Mutex field for &self mutation) 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::Value in 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 a reason slipped in after the fact does not meet the carve-out.

Formatting & Linting

  • Always cargo fmt --all and cargo 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 + cargo deny (NOT nursery — it is unstable; cherry-pick individual nursery lints like cognitive_complexity instead).

  • 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 — use get(..), strip_prefix, or pattern matching; never a manual index/slice on an untrusted length.

  • arithmetic_side_effectschecked_* + ? by default (or a justified saturating_*/wrapping_* with a comment), never silenced.

  • Prefer strip_prefix over 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 gen and ref — avoid as identifiers. In Askama templates use {% if let Some(x) %}, not Some(ref x).

SPDX Headers

Every new .rs file’s first line: // SPDX-License-Identifier: AGPL-3.0-or-later.

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: .adoc under 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 required attributes 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::Client via Arc<Client>, never Client::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.adoc under Changed/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]. Always cargo 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 in deny.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); runtime alpine:<version> (pinned).

  • Every image: non-root user, HEALTHCHECK (services), multi-stage build, a .dockerignore excluding target/, .git/, node_modules/.

  • musl ⇒ rustls, not openssl (the openssl crate is banned in deny.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 as RUNNER_SMALL/RUNNER_MEDIUM/RUNNER_LARGE variables: in .gitlab-ci.yml (GADHS dhs-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 xtask is the mandatory task runner for all automation. No shell scripts (.sh/.ps1/.bat).

  • xtask/ is a workspace member with name = "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 ServiceSettings struct.

  • Debug impl must redact secrets (database_url, rabbitmq_url, *_key, *_secret, *_password). Use secrecy::SecretString for never-print fields.

  • Validate required fields at startup — refuse to start on missing config, never silently default.

Recommended for service projects (CLI tools and libraries can skip):

  • Rate limitinggovernor per-IP on public endpoints; configurable via {PROJECT}_{SERVICE}__RATE_LIMIT_RPM (0 disables); behind a proxy, parse the real IP from x-forwarded-for once 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 with x-idempotency-replay: true on duplicates.

  • OpenTelemetry propagation — extract traceparent/tracestate from incoming requests, inject into outbound; opentelemetry + tracing-opentelemetry.

  • Prometheus metrics/metrics alongside /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 × Jurisdiction at boot; warn (or hard-bail with …__AUTHZ_REQUIRE_FULL_COVERAGE=true) on gaps.

Plan Authoring

  • All plans are .adoc under the project’s docs directory, linked in nav.adoc — Step 1 of every plan, BEFORE implementation. .claude/plans/ is ephemeral scratch only.

  • The nav.adoc link 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 init clears 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: .adoc created + 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.adoc plan sections always reflect reality (Active / Planned / Deferred / Archive).

Canonical Status Vocabulary

For Status cells in plan bodies (case-insensitive first-token match):

Token Meaning

Not started

Default for new rows

In progress

Actively worked in an open MR

Done (YYYY-MM-DD) — …

Shipped; date + freeform detail; optional MR !N

Deferred (…)

Explicitly descoped; reason required

Blocked (…)

Cannot proceed; blocker required

N/A

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-sys over reqwest + rustls-tls.

  • chrono over jiff/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::Mutex instead of parking_lot/tokio mutexes.

  • Box<dyn Error> instead of concrete thiserror enums.

  • serde_json::Value instead of typed structs "just this once".

  • Silencing unused variables with _var instead 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.

Edit this page · latest