Contribution & Commit Standards
The organization-wide standard for all open source projects
Version: 1.0.0 Applies to: Every repository in this organization. Status: Binding. CI enforces compliance. PRs that violate these standards are not merged.
Philosophy in one sentence
Every commit tells a story. Every PR is a conversation. Every review is a responsibility.
A clean Git history is not aesthetic preference: it is a diagnostic tool. When something breaks in production at 2 AM, the commit history is what tells you why. When a new contributor tries to understand a decision made six months ago, the commit body is what gives them context. When an automated tool generates a changelog or bumps a version, it reads commit types to decide what changed. This standard exists because Git hygiene compounds over time: a well-maintained history saves hours of debugging and days of onboarding.
Part 1. Commits
1.1 Format
Every commit message follows the Conventional Commits specification exactly.
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
Line length rules:
- Subject line (type + scope + description): maximum 72 characters
- Body lines: maximum 72 characters, wrap at word boundaries
- Footer lines: no limit
1.2 Types
These are the only valid types. No others. Type is always lowercase.
| Type | When to use | SemVer impact |
|---|---|---|
feat |
A new capability that users or integrators can observe | MINOR |
fix |
Corrects a defect in existing behaviour | PATCH |
docs |
Documentation only: no code change | none |
refactor |
Code change that neither adds a feature nor fixes a bug. Behaviour is identical before and after. If behaviour changes at all, it is not a refactor. | none |
perf |
Code change that improves performance without changing behaviour | PATCH |
test |
Adding or correcting tests only | none |
build |
Changes to build system, Cargo.toml, go.mod, package.json, Dockerfiles, Makefiles | none |
ci |
Changes to CI/CD workflows and scripts | none |
chore |
Maintenance that does not fit any above type: dependency bumps, lockfile updates, tooling config | none |
security |
A fix that addresses a security vulnerability. Always treated as PATCH minimum. | PATCH |
revert |
Reverts a previous commit. Must reference the reverted commit hash in the footer. | varies |
When in doubt between two types, use the one that describes purpose, not object.
Refactoring a test file → refactor, not test.
Fixing a performance bug → fix, not perf.
1.3 Scopes
Scopes are optional but strongly recommended. They identify the part of the codebase affected. Use the names that match the actual folders, crates, or services.
| Scope | What it covers |
|---|---|
engine |
crates/undolog-engine |
store |
crates/undolog-store |
types |
crates/undolog-types |
saga |
crates/undolog-saga |
proxy |
services/undolog-proxy |
sdk-py |
sdks/undolog-py |
sdk-ts |
sdks/undolog-ts |
dashboard |
apps/dashboard |
schema |
database migrations |
proto |
proto/undolog.proto |
infra |
Docker, CI, deployment |
docs |
docs/ folder |
deps |
dependency updates across any crate/service |
When a commit touches multiple scopes, use the most significant one. If genuinely equal, omit the scope.
1.4 Description
The description is the imperative-mood summary of what the commit does.
Rules:
- Imperative mood: "add", "fix", "remove", "update": not "added", "fixes", "removing"
- Lowercase first letter
- No period at the end
- Describes what the commit does, not what you did: "add BLAKE3 call signature" not "added the new BLAKE3 hashing for signatures"
- Maximum 72 characters including type and scope
Good:
feat(engine): add BLAKE3 call signature for exactly-once semantics
fix(store): correct advisory lock key on empty signature string
docs(saga): document LIFO compensation order invariant
refactor(types): extract canonical_json into dedicated module
Bad:
feat: added some stuff to the engine ← vague, past tense
fix: fixed the bug ← no scope, no information
update code ← no type, no scope
WIP ← never commit WIP to main
feat(engine): Add BLAKE3 Call Signature. ← capital letters, period
1.5 Body
The body is optional but required in these cases:
- The commit introduces a non-obvious decision
- The commit fixes a bug whose cause is not apparent from the code
- The commit changes behaviour that another developer might reverse by accident
Body rules:
- Blank line between subject and body (mandatory)
- Wrap at 72 characters
- Uses present tense for describing the current state: "The advisory lock uses FNV-1a"
- Uses past tense only when describing what changed: "Previously, the lock used CRC32"
- Answers why, not what: the diff already shows what
Example:
fix(store): prevent double-compensation on crash-recovery restart
When the Saga Orchestrator restarts after a process crash, it reloads
the undo stack and begins walking it. Previously, entries in 'running'
state would be retried even if the compensation HTTP call had already
completed: causing the compensation endpoint to receive two calls.
The fix transitions entries to 'running' only when the HTTP call is
about to be made, and checks for 'compensated' state before executing.
Idempotency keys on the compensation endpoint handle the edge case
where the state transition succeeds but the process crashes before
marking the entry as compensated.
1.6 Footers
Footers are key-value pairs after a blank line following the body (or subject, if no body).
Standard footers:
Closes #123
Fixes #456
BREAKING CHANGE: <description of what breaks and how to migrate>
Co-authored-by: Name <email>
Reviewed-by: Name <email>
Refs: #789
Breaking changes:
A commit with a breaking change MUST include BREAKING CHANGE: in the footer with a full migration description. It MAY also use ! after the type for visibility.
feat(engine)!: replace InterceptOutcome::Execute variant with ExecuteEffect
BREAKING CHANGE: The Execute variant of InterceptOutcome has been renamed
to ExecuteEffect and now carries a `tier` field alongside `effect_id`.
Before:
InterceptOutcome::Execute { effect_id }
After:
InterceptOutcome::ExecuteEffect { effect_id, tier }
Update all match arms that destructure Execute to use ExecuteEffect.
1.7 What belongs in one commit
One commit = one logical change. Not one file. Not one function. One logical change.
A logical change is a unit that:
- Can be described in a single subject line
- Can be reverted without reverting unrelated work
- Leaves the codebase in a working state (tests pass, code compiles)
Split into multiple commits when:
- A refactor is mixed with a bug fix (always separate these)
- A feature has independent parts that could be useful alone
- A dependency update is mixed with code changes that use it
Do NOT split when:
- A test and the code it tests are written together: they are one logical change
- A function and its doc comment are written together: they are one logical change
- A migration and the code that uses it must deploy together, they are one logical change
Atomic rule: Every commit in the history must compile and pass all tests. Never commit broken code to any branch, including feature branches. If you need to save work in progress, use git stash.
1.8 Commit size guideline
| Size | Lines changed | Guideline |
|---|---|---|
| Ideal | < 200 | Easy to review, easy to revert |
| Acceptable | 200–500 | Fine if it is genuinely one change |
| Large | 500–1000 | Requires justification in the body |
| Problematic | > 1000 | Must be broken up unless it is a generated file or migration |
Generated files (Cargo.lock, go.sum, protobuf output) are exempt from size limits.
Part 2. Branches
2.1 Branching strategy
This organization uses GitHub Flow: one permanent branch (main) plus short-lived feature branches. GitFlow (develop, release, hotfix branches) is explicitly rejected: it creates long-lived branches that diverge from main and make CI slower and merges more painful.
Rules:
mainis always deployable. Every commit on main passes CI.- Feature branches are short-lived: open for days, not weeks.
- No direct commits to
mainby any contributor including maintainers. mainis protected: requires PR + CI pass + 1 approval minimum.
2.2 Branch naming
<type>/<short-description>
The <type> matches the commit type the branch will produce. The <short-description> uses hyphens, lowercase, no special characters.
| Branch name | Meaning |
|---|---|
feat/saga-orchestrator |
New feature: the saga orchestrator |
fix/advisory-lock-empty-string |
Bug fix: advisory lock on empty string |
docs/adr-blake3-decision |
Documentation: ADR for BLAKE3 |
refactor/effect-store-row-mapper |
Refactor: the row mapper in effect store |
test/engine-integration-suite |
New test suite for the engine |
ci/add-rust-clippy-job |
CI change: add Clippy job |
chore/bump-sqlx-0-8-2 |
Dependency bump |
security/fix-advisory-lock-collision |
Security fix |
Rules:
- No
my-branch,temp,test123,wip, or personal name prefixes - Maximum 50 characters for the full branch name
- Delete the branch immediately after the PR is merged
- Never reuse a branch name
2.3 Branch lifetime limits
| Branch type | Maximum lifetime |
|---|---|
feat/* |
5 working days |
fix/* |
2 working days |
docs/* |
3 working days |
refactor/* |
3 working days |
chore/* |
1 working day |
Branches that exceed their limit without a PR are closed by the maintainer with a comment explaining why. The work is not lost, it is committed and the branch is deleted; the contributor opens a new PR.
Part 3. Pull Requests
3.1 PR size rules
| Category | Max lines changed | Exceptions |
|---|---|---|
| Code PR | 500 lines | None |
| Refactor PR | 800 lines | If the refactor is mechanical (rename, move) |
| Documentation PR | No limit | , |
| Migration PR | No limit | Schema-only, no logic |
| Generated files | No limit | Cargo.lock, go.sum, protobuf stubs |
If your change legitimately exceeds 500 lines, split it into a stack of PRs where each PR builds on the previous one. Open them in sequence. The description of each PR links to the others in the stack.
3.2 PR title format
PR titles follow the same format as commit subject lines:
<type>(<scope>): <description>
The PR title becomes the squash-merge commit message. It must be valid Conventional Commits format because the CI commit linter validates it.
3.3 PR description template
Located at .github/PULL_REQUEST_TEMPLATE.md: pre-filled for every PR.
3.4 PR rules
Author responsibilities (before opening the PR):
-
Self-review first. Read your own diff line by line before requesting review. Catch your own typos, debug logs, and obvious mistakes. A PR that clearly hasn't been self-reviewed will be returned without review.
-
CI must pass. Never open a PR with failing CI. Fix it first. The only exception is a draft PR opened to discuss an approach before the implementation is complete.
-
Rebase, do not merge. Keep your branch rebased on
main. Never mergemaininto your feature branch, rebase instead. A branch that is merge-polluted (contains merge commits frommain) will be asked to rebase before review. -
One concern per PR. If you discover a bug while implementing a feature, fix the bug in a separate PR first, then build the feature on top of that fix. Do not bundle unrelated changes.
-
Draft PRs are welcome. If you want early feedback on direction, open a draft PR with
[WIP]in the title. Draft PRs are not reviewed for code quality, only for architectural direction.
Reviewer responsibilities:
-
Respond within 2 working days. If you cannot review within 2 days, say so and suggest another reviewer.
-
Distinguish blocking from non-blocking feedback. Use explicit labels:
[block]: must be fixed before merge[suggest]: worth considering but not required[nit]: style preference, author's call[question]: asking for understanding, not requesting a change
-
Review the whole PR, not just the diff. Check that tests exist, docs are updated, CHANGELOG is updated, and the commit message is correct.
-
Approve means you are confident it is correct. Not "looks fine to me", confident. If you are not confident about a section, say so explicitly rather than approving anyway.
-
Be specific. "This could be better" is not useful feedback. "This function will panic if
argsis an empty JSON object becausecanonical_jsondoes not handle empty objects, see line 47" is useful feedback.
3.5 Merge strategy
Squash merge only.
Every PR is merged as a single squash commit onto main. The squash commit message is the PR title (which must be valid Conventional Commits format). The full PR description body becomes the squash commit body.
This is non-negotiable. Merge commits and rebase-merge are disabled on every repository in this organization.
Why squash:
mainhistory reads as one commit per logical change: clean and greppable- Individual commits on a feature branch can be messy during development, that is fine, they disappear on merge
git bisectworks correctly because every commit on main is a deployable stategit log --onelineon main is a readable changelog
3.6 Who can merge
- The PR author merges after receiving the required approvals and CI passes
- The author does not approve their own PR
- Maintainers may merge with 0 approvals only for:
docstype PRs,choretype dependency bumps where CI passes, and emergencysecurityfixes with a post-merge review
Part 4. Code Review Standards
4.1 What reviewers check
Correctness (always check):
- Does the code do what the description says?
- Are there edge cases not handled? (empty inputs, zero values, concurrent calls, process crashes)
- Are errors handled, or silently swallowed?
- For Rust: are there
.unwrap()calls that will panic in production? - For async code: are there potential deadlocks or race conditions?
Safety. UndoLog-specific invariants (always check):
- Does any new tool call path bypass the effect log?
- Is compensation always registered before the action executes?
- Are advisory lock keys derived identically across languages if a new algorithm is introduced?
- Is any new state transition impossible to leave the system in an inconsistent state on crash?
Tests:
- Does new behaviour have tests?
- Are invariants tested with property tests (proptest) rather than just example cases?
- Do tests test behaviour, not implementation? (Tests that break on refactoring are weak tests)
Documentation:
- Does every new
pubitem have a doc comment? - Is the CHANGELOG updated?
- Are new cross-language algorithms in the reference docs with fixture test vectors?
Style:
- Is code consistent with the surrounding file? (not just the formatter, naming, structure, comment style)
- Are error messages in the same tone as existing error messages?
- Are tracing fields in the same format as existing trace calls?
4.2 What reviewers do NOT check
- Whitespace and formatting: the formatter handles this. CI enforces it. Do not leave formatting comments.
- Personal style preferences not covered by the project standard, keep them to yourself or open a discussion to update the standard
- Whether you would have implemented it differently: only raise this if the approach has a correctness or maintainability problem
Part 5. Releases
5.1 Version numbering
Strict Semantic Versioning: MAJOR.MINOR.PATCH
| Increment | When |
|---|---|
MAJOR |
A BREAKING CHANGE footer appears in any commit since the last release |
MINOR |
A feat commit appears since the last release (no breaking changes) |
PATCH |
Only fix, perf, security commits since the last release |
| No change | Only docs, test, build, ci, chore, refactor commits |
Version 0.x.x: During pre-1.0 development, MINOR bumps MAY include breaking changes. This will be explicitly noted in the CHANGELOG.
5.2 Release process
- Update
CHANGELOG.md: move everything under[Unreleased]to a new[X.Y.Z]. YYYY-MM-DDsection - Bump version in all manifests (
Cargo.tomlworkspace package version,pyproject.toml,package.json) - Open a PR titled
chore(release): prepare v0.2.0: this is the only PR type that touches the version - After merge: tag the squash commit:
git tag v0.2.0and push the tag - The CI release job triggers on version tags: runs tests, builds binaries, publishes crates/packages, creates GitHub release with changelog section as body
5.3 Hotfixes
A hotfix is a fix or security commit that must reach users before the next planned release.
Process: branch from the version tag (git checkout -b fix/critical-bug v0.1.3), apply the fix, open PR against main, merge, tag the PATCH bump (v0.1.4), cherry-pick back to any active release branches if needed.
Part 6. Issues
6.1 Issue types
Every issue uses one of three templates (located at .github/ISSUE_TEMPLATE/):
- Bug report (
bug_report.md): for reporting defects - Feature request (
feature_request.md): for suggesting new capabilities - Documentation issue (
documentation_issue.md): for reporting documentation problems
6.2 Issue rules
- Every bug fix PR must close an issue:
Closes #XXin the footer - Feature PRs should reference an issue where the feature was discussed:
Refs: #XX - Issues are not closed without a resolution: either fixed, explicitly declined with explanation, or marked
wont-fix - Never close an issue with only "fixed": link to the commit or PR
Part 7. AI agent contribution rules
When an AI agent (Claude, Copilot, Cursor, or any other) opens commits or PRs on this repository, the same standards apply without exception. AI-generated commits that do not follow this standard will be rejected by CI.
Additional rules for AI-generated contributions:
-
Every AI-generated PR must be reviewed by a human before merge. No AI agent has auto-merge permissions.
-
The PR description must include:
Co-authored-by: <agent-name> <agent-identifier>in the footer of every commit that was primarily written by an AI agent. -
AI agents must not write
// TODO,// FIXME, or placeholder comments. If something is not implemented, it is not committed. Partial implementations must be behind a feature flag or in a draft PR. -
AI agents must run the full test suite before opening a PR. No PR from an AI agent is opened with failing tests.
-
AI agents follow the documentation standard. Every
pubitem has a doc comment. CHANGELOG is updated. If the change is architectural, an ADR is written. -
The human who triggers the AI agent is responsible for the code. If an AI agent writes incorrect or insecure code and a human approves it, the human is accountable.
Part 8. Enforcement
8.1 CI checks (automated, blocking)
All of these must pass before any PR can be merged:
| Check | Tool | What it validates |
|---|---|---|
| Commit format | commitlint |
Every commit in the PR is valid Conventional Commits |
| PR title format | commitlint (via GitHub Action) |
PR title matches type(scope): description |
| Rust formatting | rustfmt |
All Rust code is formatted |
| Rust linting | clippy --deny warnings |
No Clippy warnings |
| Rust docs | RUSTDOCFLAGS="-D missing_docs" cargo doc |
All pub items documented |
| Go formatting | gofmt -l |
All Go code is formatted |
| Go linting | golangci-lint |
No lint warnings |
| Python formatting | ruff format --check |
All Python code is formatted |
| Python linting | ruff check |
No lint warnings |
| Tests. Rust | cargo test --workspace |
All tests pass |
| Tests. Go | go test ./... |
All tests pass |
| Tests. Python | pytest |
All tests pass |
| Broken links | markdown-link-check |
No broken links in docs/ |
| CHANGELOG | Custom script | [Unreleased] section exists and is non-empty for non-chore PRs |
8.2 Human review (blocking)
- Minimum 1 approval from a maintainer
- All
[block]comments resolved - PR author has responded to every
[question]comment
8.3 What happens when standards are violated
- Commit format violation: CI fails. The author must amend or rebase to fix the commit messages before the PR can be reviewed.
- Missing doc comment: CI fails. The author adds the doc comment.
- Missing CHANGELOG update: CI fails. The author adds the entry.
- PR too large: The reviewer requests a split before starting review. The original PR is closed; smaller PRs replace it.
- Failing tests: PR is not reviewed until CI is green.
- Reviewer disagreement: If an author and reviewer disagree on a
[block]comment, a second maintainer is asked to decide. The decision is final.
This document is versioned. Changes require a PR with type docs(contributing) and must be approved by at least 2 maintainers.