Contributing to Paladin
Thank you for your interest in contributing to Paladin! This document provides guidelines and best practices for contributing to the project.
Table of Contents
- Code of Conduct
- Getting Started
- Git Hooks (pre-commit)
- Development Workflow
- Testing Guidelines
- Code Quality Standards
- Documentation
- Releasing
- Adding a New Dependency
- API Change Process
- Pull Request Process
- Community
Code of Conduct
We are committed to providing a welcoming and inclusive environment. Please be respectful and considerate in all interactions.
Getting Started
Prerequisites
- Rust: 1.70 or later (install via rustup)
- Docker: For running integration tests with Redis, MinIO, MySQL
- Git: For version control
Setting Up Development Environment
# Clone the repository
git clone https://github.com/DF3NDR/paladin-dev-env.git
cd paladin
# Build the project
cargo build
# Run unit tests
cargo test
# Start service dependencies
make dev # or docker-compose -f docker/docker-compose.dev.yml up -d
Git Hooks (pre-commit)
This repository uses the pre-commit framework to enforce formatting,
linting, secrets detection, and config validation. The hook definitions live in the
version-controlled .pre-commit-config.yaml, so every contributor gets the same checks.
Dev container users:
pre-commitis installed automatically when the container is built, and the hooks are installed on first container create. The steps below are only needed for local (non-container) setups or to (re)install the hooks manually.
1. Install pre-commit
# Recommended (isolated install)
pipx install pre-commit
# Alternatives
pip install --user pre-commit
# or your OS package manager, e.g. on Debian/Ubuntu:
sudo apt-get install -y pipx && pipx install pre-commit
2. Install the hooks
make hooks
# equivalent to:
# pre-commit install
# pre-commit install --hook-type pre-push
This wires both stages:
- pre-commit (on every
git commit):cargo fmt --check,cargo clippy, secrets detection (gitleaks), TOML/YAML validation, large-file and merge-conflict checks, trailing-whitespace and end-of-file fixes. - pre-push (on every
git push):cargo build --workspaceand the fast unit-test subsetcargo test --workspace --lib.
3. Run the hooks manually
pre-commit run --all-files # run every hook against the whole repo
pre-commit run cargo-clippy # run a single hook
Emergency override
In genuine emergencies you can bypass the hooks:
git commit --no-verify -m "..." # skip pre-commit hooks
git push --no-verify # skip pre-push hooks
Use this sparingly — CI runs pre-commit run --all-files as a required gate, so skipped checks will
still be enforced on your pull request.
Development Workflow
1. Create a Feature Branch
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
Branch naming conventions:
feature/- New featuresfix/- Bug fixesdocs/- Documentation updatesrefactor/- Code refactoringtest/- Test improvements
2. Make Your Changes
Follow the Rust coding conventions and ensure your code:
- Compiles without errors
- Passes all tests
- Is properly formatted (
cargo fmt) - Has no clippy warnings (
cargo clippy)
3. Write Tests
All code changes must include appropriate tests. See Testing Guidelines below.
4. Run Quality Checks
# Format code
cargo fmt
# Check formatting
cargo fmt --check
# Run linter
cargo clippy -- -D warnings
# Run all tests
cargo test
# Run integration tests
make test-integration-docker
5. Commit Your Changes
Use conventional commit messages:
git commit -m "feat: add Council discussion pattern"
git commit -m "fix: resolve timeout in Phalanx aggregation"
git commit -m "docs: update Garrison memory documentation"
git commit -m "test: add integration tests for Grove routing"
Commit types:
feat:- New featuresfix:- Bug fixesdocs:- Documentation changestest:- Test additions/improvementsrefactor:- Code refactoringperf:- Performance improvementschore:- Build/tooling changes
6. Push and Create Pull Request
git push origin feature/your-feature-name
Then create a Pull Request on GitHub with:
- Clear description of changes
- Link to related issues
- Test results
- Screenshots (if applicable)
Testing Guidelines
Paladin uses comprehensive testing to ensure reliability and quality. All contributions must include appropriate tests.
Test-Driven Development (TDD)
We follow the Red-Green-Refactor cycle:
- Red: Write a failing test first
- Green: Write minimal code to pass the test
- Refactor: Improve code while keeping tests green
Test Coverage Requirements
- Unit tests: ≥ 80% coverage for new code
- Integration tests: ≥ 70% coverage for public APIs
- All public APIs must have doc tests
Test Types
1. Unit Tests
Test individual functions, methods, and modules in isolation.
Location: Inline with code using #[cfg(test)] module or in tests/unit/
Example:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_paladin_builder_creates_valid_agent() { let llm_port = Arc::new(MockLlmAdapter::new()); let paladin = PaladinBuilder::new(llm_port) .name("TestAgent") .system_prompt("Test prompt") .build() .expect("Should build successfully"); assert_eq!(paladin.data.name, "TestAgent"); } #[tokio::test] async fn test_council_executes_discussion() { // Test async code let result = council_service.execute(&council, &paladins, "input").await; assert!(result.is_ok()); } } }
Run unit tests:
cargo test
cargo test test_name # Run specific test
cargo test module_name:: # Run tests in module
2. Integration Tests
Test interactions between multiple components, including external services (databases, LLMs, etc.).
Location: tests/integration/
Example:
#![allow(unused)] fn main() { // tests/integration/garrison_tests.rs #[tokio::test] async fn test_sqlite_garrison_persistence() { let garrison = SqliteGarrison::new("test.db").await.unwrap(); garrison.store_message("paladin1", Message::User("Hello".into())).await.unwrap(); let history = garrison.get_history("paladin1", 10).await.unwrap(); assert_eq!(history.len(), 1); } }
Run integration tests:
cargo test --test integration_test_name
make test-integration-docker # With Docker services
3. Snapshot Tests
Test CLI output consistency using the insta crate.
Location: tests/cli/
Example:
#![allow(unused)] fn main() { use insta::assert_snapshot; #[test] fn test_help_output() { let output = run_cli_command(&["--help"]); assert_snapshot!("help_text", output); } }
Review snapshots:
cargo test # Run tests
cargo insta review # Review new/changed snapshots
cargo insta accept # Accept all snapshot changes
Best practices:
- Use descriptive snapshot names
- Keep snapshots small and focused
- Review snapshot changes carefully before accepting
- Commit snapshot files (
.snap) to version control
4. CLI-Enabled and Library-Only Tests
The cli feature gates the application::cli module and the paladin-cli binary. Tests must reflect this boundary.
Library-only regression tests (tests/cli_isolation_test.rs): always run, no feature flag needed.
Verify that core types (Paladin, Battalion, MaxLoops, …) compile and work without cli deps:
# Run library-only isolation tests (default features, no cli)
cargo test --test cli_isolation
# Confirm library compiles with zero optional features
cargo check --lib --no-default-features
CLI feature tests (only compile with --features cli):
# Run all tests with cli feature enabled (includes snapshot tests in tests/cli/)
cargo test --features cli
# Build the paladin-cli binary
cargo build --bin paladin-cli --features cli
# Run only the CLI snapshot tests
cargo test --test cli --features cli
# Run CLI unit tests
cargo test --test unit --features cli
Both surfaces together:
# Run everything (default features + cli feature enabled)
cargo test --features cli
Note: If you add code to
application::cli, wrap any new test modules in#[cfg(feature = "cli")]when referencing them fromtests/unit/mod.rsortests/integration/mod.rs. Tests that live entirely inside thesrc/application/cli/module tree are automatically gated and need no extra attribute.
5. Live API Integration Tests
Test real LLM provider integrations (optional, requires API keys).
Location: tests/integration/llm_live_api_tests.rs
Feature flag: live-api-tests
Recommended in DevContainer (persistent workflow):
cp .env.example .env
# Edit .env and set one or more keys:
# OPENAI_API_KEY=sk-...
# DEEPSEEK_API_KEY=...
# ANTHROPIC_API_KEY=...
# Load .env for current terminal session
set -a
. /workspace/.env
set +a
Run live API tests:
cargo test --features live-api-tests -- --ignored --nocapture
Run only one provider:
cargo test --features live-api-tests test_openai -- --ignored --nocapture
cargo test --features live-api-tests test_deepseek -- --ignored --nocapture
cargo test --features live-api-tests test_anthropic -- --ignored --nocapture
Without API keys, tests will be ignored/skipped:
cargo test --features live-api-tests
# Tests remain ignored unless --ignored is supplied
5. Benchmark Tests
Performance benchmarks using Criterion.
Location: benches/
Example:
#![allow(unused)] fn main() { use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn benchmark_formation(c: &mut Criterion) { c.bench_function("formation_3_agents", |b| { b.iter(|| { // Benchmark code black_box(formation.execute(input).await); }); }); } criterion_group!(benches, benchmark_formation); criterion_main!(benches); }
Run benchmarks:
cargo bench # Run all benchmarks
cargo bench --no-run # Check compilation only
Running Different Test Types
# All tests
cargo test --all-features
# Unit tests only
cargo test --lib
# Integration tests only
cargo test --test '*'
# Specific test file
cargo test --test garrison_tests
# With output
cargo test -- --nocapture
# CLI-enabled tests (requires cli feature)
cargo test --features cli
# Library-only isolation tests (no cli feature)
cargo test --test cli_isolation
# Live API tests (requires API keys)
cargo test --features live-api-tests
# Benchmarks
cargo bench
# With coverage
cargo llvm-cov --html --output-dir target/coverage
cargo tarpaulin --out Html
Mocking and Test Doubles
For testing code that depends on external services, create mocks:
#![allow(unused)] fn main() { use async_trait::async_trait; struct MockLlmAdapter { responses: Vec<String>, } #[async_trait] impl LlmPort for MockLlmAdapter { async fn generate(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError> { Ok(LlmResponse { content: self.responses[0].clone(), // ... other fields }) } } // Use in tests let mock = Arc::new(MockLlmAdapter::new()); let paladin = PaladinBuilder::new(mock).build()?; }
Test Organization
tests/
├── unit/ # Unit tests (if not inline)
│ ├── mod.rs
│ └── paladin_test.rs
├── integration/ # Integration tests
│ ├── mod.rs
│ ├── garrison_tests.rs
│ ├── arsenal_tests.rs
│ └── battalion_tests.rs
├── cli/ # CLI snapshot tests
│ ├── mod.rs
│ ├── table_output_test.rs
│ ├── error_output_test.rs
│ └── snapshots/ # Snapshot files (.snap)
└── fixtures/ # Test data and fixtures
└── sample_data.json
Code Quality Standards
Rust Coding Conventions
- Follow Rust API Guidelines: https://rust-lang.github.io/api-guidelines/
- Use
rustfmt: Automatic code formatting - Use
clippy: Catch common mistakes - Document public APIs: All public items need rustdoc comments
Code Formatting
# Format all code
cargo fmt
# Check formatting without modifying
cargo fmt --check
Configuration in rustfmt.toml:
- Max width: 100 characters
- Use tabs: false (4 spaces)
- Edition: 2021
Linting
# Run clippy with warnings as errors
cargo clippy -- -D warnings
# Fix auto-fixable issues
cargo clippy --fix
Documentation
All public items must have documentation:
#![allow(unused)] fn main() { /// Creates a new Paladin agent with the specified configuration. /// /// # Arguments /// /// * `llm_port` - The LLM provider port for agent execution /// /// # Returns /// /// A configured `PaladinBuilder` instance /// /// # Examples /// /// ``` /// use paladin::prelude::*; /// /// let builder = PaladinBuilder::new(llm_port) /// .name("Assistant") /// .system_prompt("You are helpful"); /// ``` pub fn new(llm_port: Arc<dyn LlmPort>) -> Self { // implementation } }
Generate and view documentation:
cargo doc --no-deps --open
Security
- Never commit API keys or secrets
- Use environment variables for configuration
- Add sensitive values to
.gitignore - Run dependency security & license checks:
make security(runscargo audit+cargo deny check) - Generate a Software Bill of Materials:
make sbom
Vulnerability advisory exceptions live in .cargo/audit.toml (and are mirrored
in deny.toml). Never disable a security or license check to make CI pass —
follow the documented exception process instead. See
docs/SECURITY_SCANNING.md for the full tooling
overview, license policy, and advisory exception process.
Documentation
Types of Documentation
-
Code Documentation (rustdoc)
- Document all public APIs
- Include examples in doc comments
- Explain complex algorithms
-
User Guides (
docs/)- Installation instructions
- Quickstart guides
- Feature documentation
- Examples and tutorials
-
Architecture Documentation (
docs/Design/)- System architecture
- Design decisions
- Technical specifications
-
API Documentation (generated)
- Comprehensive API reference
- Generated from rustdoc comments
Documentation Guidelines
- Write clear, concise documentation
- Include code examples
- Keep documentation up-to-date with code changes
- Use proper markdown formatting
- Add diagrams where helpful
Per-Crate Changelog Maintenance
Each public crate under crates/ must keep a CHANGELOG.md following Keep a Changelog format.
- Update the crate changelog whenever public API, feature flags, or release-facing behavior changes.
- Keep crate entries aligned with the workspace lockstep versioning policy in
docs/VERSIONING_POLICY.md. - When creating a crate changelog for the first time, backfill relevant items from the root
CHANGELOG.md. - Keep crate README and changelog updates together so release artifacts remain consistent.
Releasing
Releases are automated with cargo-release and the
tag-triggered .github/workflows/release.yml pipeline. The full evaluation, decision, and operator
guide live in docs/RELEASE_AUTOMATION.md; the manual checklist is in
docs/RELEASE_CHECKLIST.md.
Releases are cut only from
main. Release tags (v*.*.*) must point at a commit that is contained inmain; theverify-tag-sourceCI guard fails the pipeline otherwise, andmake releaserefuses to run from any other branch. See docs/BRANCH_PROTECTION.md for the policy and its enforcement layers.
Cutting a release
A release is cut locally with a single command (CI does the publishing):
# 0. Ensure your release commit is merged and you are on an up-to-date main.
git checkout main && git pull --ff-only origin main
# Bumps all crates in lockstep, finalizes CHANGELOG.md, commits, tags v<version>, and pushes.
make release VERSION=0.4.0
make release:
- Validates
VERSIONis valid semver (fails fast otherwise). - Runs
make release-check(format, lint, full tests, audit, release build). - Bumps every public crate to
VERSIONin lockstep viacargo release versionand updates internal dependency pins. - Moves the
## [Unreleased]changelog section under a new## [VERSION] - <date>heading. - Commits, creates the
v VERSIONtag, and pushes the branch and tag.
Pushing the v*.*.* tag triggers the release pipeline, which runs the test suite and then publishes
the crates to crates.io in dependency order (paladin-core → paladin-ports → leaf crates →
paladin), builds Docker images and binaries, generates the SBOM, and creates the GitHub release.
Install the tool once with:
cargo install --locked cargo-release
Required secret
crates.io publishing requires a repository secret CARGO_REGISTRY_TOKEN (a crates.io API token
with publish scope). If it is not set, the publish job is skipped with a warning and the rest of the
release still runs.
Dry run (no live publish)
Validate publishing without releasing to crates.io:
# Local: dependency-first `cargo publish --dry-run` for every crate.
make publish-dry-run
# CI: exercise the whole pipeline with no real publish.
gh workflow run release.yml -f tag=v0.4.0-rc.1 -f dry_run=true
Adding a New Dependency
Before adding any new crate to a Cargo.toml, follow these steps to keep the project's
license policy and security posture clean.
-
Add the crate using
cargo add <crate>(or editCargo.tomldirectly and runcargo fetch). Prefer crates with MIT, Apache-2.0, or BSD-class licenses. -
Check the license — run
make deny(orcargo deny check) locally:make deny # equivalent to: cargo deny checkIf
cargo-denyrejects the license, the crate is not permitted under the current policy indeny.toml. Do not add a license exception without team discussion. Open an issue or PR comment explaining why the crate is necessary and what the licensing implications are. -
Check for vulnerabilities — run
make audit(orcargo audit):make audit # equivalent to: cargo auditA new dependency must introduce zero new vulnerability errors. If
cargo auditreports a vulnerability advisory for the crate, choose a patched version or an alternative crate. -
Handle unmaintained advisories — if
cargo-denyorcargo auditsurfaces an unmaintained advisory (not a CVE) for the new dependency:-
Evaluate whether the crate is still safe to use.
-
If acceptable, add a scoped ignore entry in
deny.tomlwith a comment explaining the rationale and a review date:# [deny.toml] [advisories] ignore = [ # RUSTSEC-XXXX-XXXX: <crate> is unmaintained but has no known exploit paths # and is only used for <purpose>. Review at next minor version bump. { id = "RUSTSEC-XXXX-XXXX", reason = "<rationale>" }, ] -
Mirror the entry in
.cargo/audit.tomlso both tools agree.
-
-
Update
CHANGELOG.md— if the new dependency enables a user-visible feature or behavioral change, add a line to the## [Unreleased]block describing what changed. -
CI is the final gate — the
cargo-denyandsecurity-auditCI jobs run on every push and are required to pass before merging. Do not bypass them withSKIPor--no-verify.
Quick reference:
cargo add <crate> # add the dependency make deny # verify license compliance make audit # verify no new CVEs
API Change Process
Paladin maintains a stable public API contract defined in STABLE_API.md. This document defines:
- Stability guarantees for all public types and traits
- Versioning policy (semantic versioning interpretation)
- Stability tiers (Stable 🟢, Unstable 🟡, Experimental 🔵, Deprecated 🔴)
- Catalog of stable APIs with fully qualified paths
- Change approval process for breaking changes
- Migration guides and deprecation lifecycle
All changes to the public API must follow the process below. See STABLE_API.md for complete details on API stability and the catalog of stable types.
What is Considered a Public API Change?
Changes to any of the following require the API change process:
- Port traits (all traits in
src/application/ports/) - Domain entities (types in
src/core/platform/container/) - Builders (PaladinBuilder, CommanderBuilder, etc.)
- Configuration types (ApplicationSettings, etc.)
- Error types (all public error enums)
- Public exports from
src/lib.rs
Process for Non-Breaking API Changes
Non-breaking changes include:
- Adding new methods with default implementations to traits
- Adding new types/modules
- Adding new optional parameters with defaults
- Expanding enum variants (with
#[non_exhaustive])
Steps:
- Make the changes
- Add comprehensive rustdoc with examples
- Run API tracking:
./scripts/extract-public-api.sh - Review the diff:
./scripts/check-api-surface.sh - Update
CHANGELOG.mdunder "Added" section - Submit PR with "feat:" prefix
- After approval, update baseline:
./scripts/extract-public-api.sh project/current-exports.txt
Process for Breaking API Changes
Breaking changes include:
- Removing public types, traits, or methods
- Changing method signatures
- Removing trait methods
- Changing error types
- Renaming public items
Steps:
-
Open an Issue First
- Describe the breaking change
- Explain the motivation
- Propose the migration path
- Get consensus from maintainers
-
Add Deprecation Warning (for removals)
#![allow(unused)] fn main() { #[deprecated(since = "0.2.0", note = "Use `NewType` instead. See MIGRATION.md for details.")] pub struct OldType { /* ... */ } } -
Update Documentation
- Add migration guide to
docs/MIGRATION.md - Update
STABLE_API.mdwith new API - Update all examples
- Update rustdoc with examples
- Add migration guide to
-
Run Deprecation Checks
./scripts/check-deprecations.sh -
Update CHANGELOG
- Add entry under "Breaking Changes" section
- Link to migration guide
-
Submit PR
- Use "feat!:" or "fix!:" prefix (note the
!) - Include breaking change details in PR description
- Reference the tracking issue
- Use "feat!:" or "fix!:" prefix (note the
-
After Approval
- Update API baseline:
./scripts/extract-public-api.sh project/current-exports.txt - Version will be bumped according to semver (0.x.0 → 0.y.0 or x.0.0 → y.0.0)
- Update API baseline:
API Tracking Scripts
# Extract current public API surface
./scripts/extract-public-api.sh project/current-exports.txt
# Check for API changes (CI uses this)
./scripts/check-api-surface.sh project/current-exports.txt
# Verify deprecation warnings compile correctly
./scripts/check-deprecations.sh
CI Enforcement
The CI pipeline automatically:
- Checks for API surface changes
- Fails if API changed without updating baseline
- Validates deprecation warnings compile
- Ensures all public items have rustdoc
If CI fails due to API changes:
- Review the diff shown in CI output
- Verify changes are intentional
- Follow the appropriate process above
- Update the baseline if approved
Examples of API Changes
✅ Non-Breaking - Adding Optional Method:
#![allow(unused)] fn main() { pub trait LlmPort: Send + Sync { async fn generate(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError>; // New method with default implementation async fn generate_with_retry(&self, request: &LlmRequest, retries: u32) -> Result<LlmResponse, LlmError> { // Default implementation self.generate(request).await } } }
❌ Breaking - Changing Method Signature:
#![allow(unused)] fn main() { // Old async fn generate(&self, prompt: &str) -> Result<String, LlmError>; // New (BREAKING!) async fn generate(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError>; }
✅ Correct Way - Deprecate Then Remove:
#![allow(unused)] fn main() { // Version 0.1.0 - Original async fn generate(&self, prompt: &str) -> Result<String, LlmError>; // Version 0.2.0 - Add new, deprecate old #[deprecated(since = "0.2.0", note = "Use `generate_with_request` instead")] async fn generate(&self, prompt: &str) -> Result<String, LlmError>; async fn generate_with_request(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError>; // Version 1.0.0 - Remove deprecated async fn generate_with_request(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError>; }
Questions?
For questions about API changes:
- Review STABLE_API.md
- Open an issue with the
api-stabilitylabel - Ask in GitHub Discussions
Pull Request Process
Before Submitting
- ✅ All tests pass (
cargo test --all-features) - ✅ Code is formatted (
cargo fmt --check) - ✅ No clippy warnings (
cargo clippy -- -D warnings) - ✅ Documentation is updated
- ✅ Commit messages follow conventions
- ✅ Branch is up-to-date with main/develop
PR Description Template
## Description
Brief description of changes
## Motivation
Why is this change necessary?
## Changes
- List of changes made
- Breaking changes (if any)
## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] All tests pass
- [ ] Benchmarks run (if applicable)
## Documentation
- [ ] README updated
- [ ] API documentation updated
- [ ] Examples added/updated
## Checklist
- [ ] Code follows project conventions
- [ ] Tests pass locally
- [ ] No clippy warnings
- [ ] Documentation complete
Review Process
- Automated checks run (CI/CD)
- Code review by maintainers
- Address review feedback
- Approval and merge
Community
Getting Help
- Documentation: docs/README.md
- Examples: examples/
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Reporting Issues
When reporting issues, include:
- Rust version (
rustc --version) - Operating system
- Steps to reproduce
- Expected vs actual behavior
- Error messages and stack traces
Feature Requests
Feature requests are welcome! Please:
- Search existing issues first
- Describe the use case
- Explain why the feature is valuable
- Consider contributing the implementation
License
By contributing to Paladin, you agree that your contributions will be licensed under the MIT License.
Thank you for contributing to Paladin! 🏰