Contributing
This chapter is the contributor reference. It covers what we expect of pull requests, the testing requirements (including the non-negotiable DST discipline), the AX-NNN tracking convention, and the naming and visibility conventions that show up at code review.
The chapter has two halves. The first half is contributor-facing
guidance specific to working on axess. The second half is the
canonical CONTRIBUTING.md
from the repo root, included so the workflow checklist is in one
place.
Before you open a PR
Three things to do before you open a PR.
The first is to read or skim Architecture at a glance. The verifier-versus-orchestrator boundary, the three state slices, the DST discipline, and the naming conventions are the four architectural decisions that the review process holds new code against. A PR that violates one of them is harder to land; a PR written with them in mind sails through.
The second is to find or create an AX-NNN tracking entry. The
ROADMAP is the source of truth for "what is being worked on" and
"what is committed." A PR that lands a feature should reference
an AX-NNN. A PR that lands a bug fix can do without (though one
is often associated even with fixes). The number lives in the
PR description and in the commit messages; the format is
AX-NNN (no #, no space).
The third is to discuss substantial changes before writing them. The review cycle is faster when the maintainers have agreed to the shape ahead of time. A drive-by PR that rewrites a module is usually rejected even when the rewrite is well-thought-out; the cost of integration is higher than the value of the rewrite. A discussion (an issue, a draft PR description, a comment in an existing thread) before the work starts is the shape that lands.
Testing requirements
Every change passes its tests under both the production and the
mock implementations of Clock, SecureRng, and the backend
traits. The DST discipline is the testing non-negotiable; it is
not aspirational.
A test that fails on the production implementation but passes on the mock is detecting a real bug in the production code (or in the test). A test that fails on the mock but passes on production is detecting either a real timing-dependent bug or an over-strict test; either way it is worth investigating before landing.
The pattern in the test code is to parameterise:
#[tokio::test]
async fn login_succeeds_with_correct_password() {
let suite = TestSuite::default(); // sets up the mocks
let outcome = suite.service
.verify_factor(
&suite.session(),
FactorCredential::Password("Gnomes2+".into()),
)
.await
.unwrap();
assert!(matches!(outcome, FactorOutcome::Authenticated));
}
TestSuite::default() wires MockClock, MockRng,
MockBackend, MockRegistry, the in-memory session store, and
the in-memory device store. The test runs entirely in process,
deterministically, against a known initial state.
For tests that need a real database (integration tests that verify SQL adapters), the pattern is to feature-gate them and run them in CI under a service container:
#[tokio::test]
#[ignore = "requires Postgres"]
async fn postgres_session_round_trip() {
let pool = sqlx::PgPool::connect(env_var("TEST_POSTGRES_URL")?).await?;
// ... full integration test
}
The #[ignore] attribute keeps the test out of the default
cargo test run; the CI runs them explicitly with
cargo test --features integration -- --ignored. The pattern
keeps the inner loop fast (default cargo test is in-process)
while still exercising the integration tests in CI.
What good PR descriptions look like
The PR description is what reviewers read first. The goal is to explain what the PR does, why, and what to look for. The shape:
A one-sentence summary at the top. "Add the BearerToken
factor for inbound API authentication." Not "Misc fixes." The
summary is what shows up in the PR list and in the commit
history.
A "Why" paragraph. What problem does the change solve. The problem might be a documented bug, a missing capability, an operational signal that needs response. The reviewer's first question after "what" is always "why now"; answer it in the description rather than the comments.
A "How" section. The shape of the change. Which modules touched, which traits added or modified, which tests added. The reviewer's first question after "why" is "where to look"; the section is the map.
A "Testing" section. What tests cover the change. The default expectation is unit tests against the mocks; integration tests where the change crosses an integration boundary; manual testing notes for changes that are hard to automate (typically migrations or operational tooling).
A "Migration" section if the change is breaking. What downstream code has to update. The section is what feeds the Migration guide chapter; the maintainers add the entry there as part of the merge, but the PR author drafts the wording.
A reference to the AX-NNN tracking number. If the work is substantial, the AX entry has the larger context; the PR description summarises the slice this PR delivers.
Naming and visibility
The naming conventions from Architecture at a glance are enforced at review. The shapes:
A type that is shared across authentication and authorisation
uses the Auth* prefix. A type used only for authentication
uses Authn*. A type used only for authorisation uses Authz*.
A type that does not fit any of the three either picks one
(typically the broader one) or argues in the PR description
why the convention does not apply.
A type's suffix carries its role. *Store, *Registry,
*Provider, *Resolver, *Config, *Error, *Outcome,
*Decision. A new type that does not fit any of these picks
the closest match or argues in the PR description; the
conventions are tight, but they are not exhaustive, and the
rare exception is acceptable when documented.
A method's verb carries its complexity. get_* is O(1) by
primary key. find_* may scan. load_* and save_* are
serialisation pairs. begin_* and complete_* are ceremony
starts and finishes. verify_* is a credential check. A
method that does not fit any of these picks the closest match.
Visibility defaults to pub(crate). A type is promoted to
pub only when an external consumer needs it; the default is to
not export, and the burden is on the PR to justify the
promotion. The convention catches the common case where an
internal helper accidentally becomes public surface that has to
be maintained forever.
The no-#[non_exhaustive] policy
Axess does not use #[non_exhaustive] on its public enums and
structs. The attribute trades exhaustiveness checking (the
downstream compiler does not catch missing match arms) for
backward compatibility (the upstream can add variants without
breaking downstream). For axess, the trade is the wrong way
around: missing match arms in the downstream are bugs we want to
catch, and the backward-compatibility cost of adding variants is
manageable through deprecation cycles and the migration guide.
A PR that adds #[non_exhaustive] to a public type is rejected
unless the reasoning in the PR description argues a specific
case. The default is to bump the semver major version when a
variant is added, document the change in the migration guide,
and let the downstream's compiler catch the missing arm.
The DST non-negotiable
The DST discipline is reproduced from Architecture at a glance as a contributor reminder:
Every code path that reads wall time goes through the Clock
trait. Every code path that sources entropy goes through the
SecureRng trait. Every backend trait has a mock implementation
that the tests use. A PR that introduces a chrono::Utc::now()
call, a getrandom() call, or a direct database read outside
the trait surface is rejected.
The exceptions are extremely narrow: the axess-cache crate's
moka-cache feature uses wall-clock-driven eviction (opt-in,
documented as DST-breaking), and the production SystemClock
and SystemRng implementations delegate to the OS (these are
the only places where the OS calls happen). New code introduces
neither another exception nor a workaround that hides the same
problem.
The discipline is what lets the test suite be reproducible. A contributor who finds the discipline frustrating is usually about to introduce a bug; the friction is the point.
Canonical CONTRIBUTING.md
The rest of this chapter is the canonical CONTRIBUTING.md from
the repo root.
Contributing to Axess
Thanks for your interest! Axess accepts bug reports, feature requests, documentation improvements, and code contributions.
Before opening a PR for non-trivial work, please file an issue first; this lets us flag overlap with in-flight work in ROADMAP.md and confirm the change fits the library's direction (see docs/intro/architecture.md) before you invest time.
Before you submit
- Fork the repository and create a topic branch from
main. - Tests; add or update tests for every behaviour change. The library uses deterministic simulation testing (DST); inject
MockClock/MockRngrather than callingSystemTime::now()orrand::rng()directly. - Run the full check locally:
cargo fmt --all cargo clippy --workspace --all-features --lib --tests -- -D warnings cargo test --workspace --all-features - Update
CHANGELOG.md; add an entry under the[unreleased]section describing the change. Behaviour-changing entries belong under### Changed (breaking)if they alter a public API. - Open a PR with a description that covers the why; link the issue, summarise the design choice, and call out any deliberate trade-offs.
Coding conventions
- Idiomatic Rust,
async/awaitfor IO,thiserrorfor error types,tracingfor logs. - Prefer traits + generics on hot paths; vtable dispatch (
Box<dyn …>) only where it earns its keep. - Public APIs need rustdoc; including at least one usage example for newly-introduced traits or builders.
- All time + randomness goes through the
Clock/SecureRngtraits. This is non-negotiable; it's what makes the test suite deterministic.
See .github/copilot-instructions.md for the full house style.
Workspace layout
| Crate | Role |
|---|---|
axess | Public facade: middleware builder, re-exports, feature gates |
axess-core | Core types, session orchestrator, Cedar authz integration, on-behalf-of credential storage + token exchange |
axess-cache | Generic clock-aware TTL cache |
axess-clock | Clock / MockClock traits for DST |
axess-events | rkyv-serialisable audit event types |
axess-factors | Authentication factor implementations |
axess-identity | Newtype ID macros + impls |
axess-macros | Procedural macros for route guards |
axess-rng | SecureRng / MockRng traits |
axess-strings | Short hot-path string primitive |
examples/* | Reference example applications |
Repository conventions
A few rules that aren't obvious from reading the code but affect every PR. Most exist because the cost of not following them showed up somewhere.
Module layout
axess uses the modern Rust convention: foo.rs + a sibling foo/ directory holding submodules. No mod.rs files in new code. Every directory module declares its submodules in the foo.rs file next to (not inside) the directory.
Test-sideways-pull
When #[cfg(test)] tests crowd a production file enough to make scrolling expensive, pull them into a sibling tests.rs:
axess-core/src/path/file.rs ; production code +
#[cfg(test)] mod tests;
axess-core/src/path/file/tests.rs; the actual tests, gated by
#![cfg(test)]
Applied so far across several files where the tests-to-production ratio exceeded ~40%.
pub(crate) for state-machine internals
AuthSession carries identity / session-state accessors as pub. State mutation methods (set_authenticated, begin_authenticating, advance_factor, record_attempt_at) are state-machine transitions that the factor pipeline drives; they are pub(crate) so handler code cannot corrupt the state machine. Adopters drive flow through AuthnService; the session is read-only-ish from outside axess-core.
Per-app workflow mutations (set_identifying, set_pending_workflow, clear, regenerate) remain pub; apps build their own two-step identify / workflow-step / logout flows on top.
No #[deprecated] pre-v0.1.0
Breaking changes happen freely in the unreleased [0.2.0] window; adopters get one coordinated migration window, not a long #[deprecated] trail. CHANGELOG documents each break under ### Changed (breaking).
MSRV bumps are breaking changes
The workspace pins rust-version = "1.87" in [workspace.package]. A bump to a higher MSRV requires a minor-version bump on every published crate (0.x → 0.x+1 for 0.x; 1.x → 1.x+1 once stable). The reasoning: adopters pin Rust toolchains in CI; jumping the floor without warning silently breaks their builds.
Procedure for an MSRV bump:
- Justify in the PR description (which compiler feature, why it earns the bump).
- Update
rust-versionin[workspace.package]AND theMSRVjob's toolchain pin in.github/workflows/ci.yml. - Add an entry under
### Changed (breaking)in CHANGELOG.md naming the new floor. - Bump the workspace
version(in[workspace.package]) accordingly.
No #[non_exhaustive] on first-party enums
#[non_exhaustive] trades one breakage class (adding variants) for another (every downstream match needs a wildcard arm forever, even when the caller wants compile-time exhaustiveness on a closed set). Project policy is to bump the version and let downstream match failures be loud. CI enforces this; the ban_non_exhaustive workflow job rejects any PR that introduces the attribute.
No ticket-meta date stamps pre-v0.1.0
Source-code comments do not carry // AX-NNN (YYYY-MM-DD): markers. The CHANGELOG is the authoritative timeline; in-source stamps add noise without information a future reader can use. ROADMAP + CHANGELOG retain their AX-NNN references unchanged.
Closed AX-NNN references get stripped
Once an AX-NNN case closes, every reference in source / doc-strings / test names is stripped, preserving the rationale comment but dropping the case number. Open + deferred cases stay referenced.
Promoting a module out of axess-core
axess-core has accumulated significant surface. When proposing a new crate carve-out, check:
- No reverse dep from axess-core onto the carved module. If the module's types appear in
AuthnServicemethod signatures or in any axess-core trait surface, the carve isn't yet feasible; invert the dependency first. - Module has its own external dep blast. Carving
delegated/intoaxess-delegatedwon because it pullsaes-gcmonly when adopters opt in. A carve that pulls no extra deps is just churn. - Module is consumable in isolation. A consumer who wants only the carved module should not transitively recompile axess-core's protocol surface.
- Re-export via the facade preserves the import path. Adopters write
axess::middleware::ratelimit::*, notaxess_middleware::ratelimit::*. The facade decides the shape.
Security
Do not open public issues for security vulnerabilities. Report them privately per SECURITY.md.
Licensing
By contributing, you agree your contribution will be dual-licensed under MIT and Apache-2.0, matching the project licence.
Community
Be respectful and constructive. See CODE_OF_CONDUCT.md.
Maintainer time is volunteer-funded; review turnaround is best-effort.
Further reading
Architecture at a glance covers the architectural decisions
that review enforces. Publishing runbook covers the
maintainer-only release process. The
CHANGELOG.md
catalogues what each release has shipped, which is useful
context for understanding what the next PR is meant to do.