---
id: 52
title: "Conventional Commits: the log is an interface, not a diary"
status: current
kind: note
date: 2026-08-02
authors:
  - "Theo Zourzouvillys"
tags: [process, interop, llm, principles]
summary: "Write every commit message as Conventional Commits — type, scope, breaking marker, prose body, reference footers. The log is the only documentation permanently welded to the change; structure is what makes it queryable and versions derivable. Enforce it in CI."
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-1: "Where the reasoning actually lives — a commit footer cites the decision record rather than re-arguing it in every message."
  ZFN-14: "The same move applied to APIs: define the contract as a schema and generate from it, instead of letting each author improvise the shape."
  ZFN-26: "Why an LLM-drafted commit message needs no disclaimer but does need someone who read it and can defend it."
  ZFN-28: "The failure this format cannot fix — a well-formed message on a change nobody understands."
  ZFN-30: "Why to adopt the published commit spec rather than growing a house prefix scheme that means the same thing, badly."
  ZFN-45: "Read the spec itself before adopting it; it is two pages and settles most of the arguments teams have about it."
  ZFN-47: "Scope names are a shared vocabulary across teams, so they need an owner and a change process like any boundary contract."
  ZFN-51: "The same envelope-versus-payload split, applied to a commit: metadata about the change belongs in defined fields, not smeared into free text."
references:
  - id: conventionalcommits
    title: "Conventional Commits 1.0.0"
    url: https://www.conventionalcommits.org/
    abstract: "A lightweight specification for commit messages that adds a small structured header — a type, an optional parenthesised scope, an optional `!` breaking marker, and a description — followed by an optional prose body and git-trailer-style footers including `BREAKING CHANGE:`. It is deliberately short, defines the mapping onto Semantic Versioning, and is designed to be parsed by release tooling."
  - id: semver
    title: "Semantic Versioning 2.0.0"
    url: https://semver.org/
    abstract: "Defines MAJOR.MINOR.PATCH so a version number carries a compatibility promise: patch for backwards-compatible fixes, minor for backwards-compatible additions, major for anything that breaks a documented contract. Conventional Commits exists largely to let that number be derived from the log rather than chosen by hand."
  - id: angular
    title: "Angular commit message guidelines"
    url: https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md
    abstract: "The in-repo convention Conventional Commits was generalised from, and still the origin of the canonical type vocabulary (feat, fix, docs, refactor, perf, test, build, ci, chore). Worth reading for how a large project actually applies the format day to day, including its rules on scope and on what belongs in the body."
  - id: tpope
    title: "Tim Pope — A Note About Git Commit Messages"
    url: https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
    abstract: "The 2008 post that established the conventions git tooling still assumes: a short capitalised summary line in the imperative mood, a blank line, then wrapped prose explaining the motivation for the change and contrasting it with previous behaviour. Predates Conventional Commits and covers the half that Conventional Commits deliberately does not specify — whether the message is any good."
  - id: commitlint
    title: "commitlint"
    url: https://commitlint.js.org/
    abstract: "A linter that checks commit messages against a configured ruleset, shipped with a Conventional Commits preset. Runs as a `commit-msg` hook for immediate local feedback and as a CI job for enforcement, and can be pointed at a pull request title as well as at individual commits."
---

## TL;DR

Every commit message, in every repository, follows [Conventional Commits](ref:conventionalcommits):

```
type(scope)!: imperative summary, lower case, no trailing period

Why this change exists and what it rules out. Wrapped prose — the part
that cannot be recovered from the diff.

BREAKING CHANGE: <what breaks, and what a caller has to do about it>
Refs: DR-12
```

- **The header is structured data.** A type from a small closed vocabulary, an optional scope, `!`
  for a breaking change. This is the part machines read.
- **The body is prose, and it is the actual message.** The specification says nothing about whether
  a commit message is any *good*. `fix(auth): fix bug` is perfectly conventional and worthless.
- **`BREAKING CHANGE:` is a claim about compatibility**, not decoration. If a release pipeline reads
  it, a missing one ships an incompatible change as a patch.
- **Footers carry references** — decision record ids, issue ids, co-authors — so the commit points at
  the argument instead of restating it.
- **Enforce it mechanically**, in CI, blocking. Including on squashed pull request titles, which is
  the step almost everyone misses.
- **Don't invent a house prefix scheme.** This one is specified, stable, and already has tooling
  ([ZFN-30](/zfn/30-use-standards-dont-reinvent/)).

## Context

The commit message is the only piece of documentation permanently welded to the change it describes.

Everything else detaches. A pull request description lives in a forge's database, and forges get
migrated, acquired, or abandoned; what survives the export is rarely what you wanted. Ticket ids
outlive at most two trackers. A code comment describes the state the code is in, never the
transition it went through or the option that was rejected on the way. Design docs describe the
plan, which is not always what shipped. The log is the one artifact that travels inside the
repository, forever, and it is what you actually reach for at two in the morning when a line makes
no sense and you need to know what its author was worried about.

And in most repositories it is the least disciplined text in the project. That is not laziness so
much as structural: nobody reviews a commit message, it can't be edited once it's pushed, its reader
is a stranger — usually a future version of you who has forgotten everything — and the feedback loop
between writing it badly and needing it is measured in years. Every incentive at the moment of
writing points at typing `fix stuff` and moving on.

Two things changed that make this worth fixing now rather than accepting as a fact of life.

The first is that **tooling which reads the log became standard**. Changelog generation, version
derivation, release automation, "what shipped in this deploy" — all of it wants to parse the history,
and prose is not parseable. Deriving the version number from the log rather than choosing it by hand
is only possible if each commit declares what kind of change it is
([SemVer](ref:semver) is a promise about compatibility, and something has to make that promise).

The second is that **agents now write most commits and read the history to understand the code**.
That cuts both ways, and both ways favour a format. An LLM applies a documented convention exactly
and without fatigue, which is precisely what humans are worst at over a thousand commits — the
convention stops decaying by the third contributor. And on the reading side, an agent doing
archaeology on a subsystem is querying `git log`; a structured, greppable history is one it can
answer questions from, and a wall of `wip` is one it can only guess at. This is the same asymmetry as
[ZFN-1](/zfn/1-engineering-decision-records/): models are excellent at applying rules they can find
and hopeless at inferring rules from absence.

There is a shape here worth naming, because it is the same one as
[ZFN-51](/zfn/51-design-the-request-envelope-first/). A commit has an **envelope** — metadata *about*
the change: what kind it is, what it touches, whether it breaks anything, what it references — and a
**payload**: the prose explaining why. If the envelope has no defined home, it doesn't disappear;
it gets smeared into the free-text body, where nothing can act on it. `[URGENT] fix login (this
breaks the SDK, see TICKET-4021)` contains every field this note asks for, and not one of them is
addressable by anything except a human reading carefully.

The realistic alternative to adopting the spec was never "no convention." It is a local convention,
undocumented and unenforced: `[fix]`, then `FIX:`, then `fix -`, then `Fix -`, diverging quietly with
each new contributor until nothing can be parsed and nobody remembers there was a rule. That is the
reinvention [ZFN-30](/zfn/30-use-standards-dont-reinvent/) is about, in miniature. The specification
is two pages, it is stable at 1.0.0, and the linters and release tools are already written
([ZFN-45](/zfn/45-read-the-standards/) — read it, it settles most of the arguments teams have about
it).

## Recommendation

**Adopt Conventional Commits verbatim, enforce it in CI, and keep the type vocabulary closed.**

### Keep the type list short and boring

The taxonomy is the only part of this anyone argues about, and the argument has no payoff. Take the
[Angular set](ref:angular) as given — `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `build`,
`ci`, `chore` — and resist every proposal to add `wip`, `hotfix`, `style`, or `security`. Two rules
settle most disputes:

- **`feat` and `fix` are the only types that describe a change in observable behaviour** for someone
  outside the repository. They are the two that drive the version number. Everything else is a
  bucket, and which bucket a change lands in matters far less than people argue as though it does.
- **On the `feat`/`fix` boundary, ask what a consumer who read the documentation would expect.** New
  capability that wasn't promised is a feature; restoring behaviour that was promised and wasn't
  delivered is a fix.

### Treat scope as a vocabulary with an owner

Scope earns its keep in a monorepo and is close to dead weight in a single-service repository. If you
use it, the legal scopes are an **enumerated list the linter validates** — for the same reason an
envelope enumerates its fields rather than accepting arbitrary keys. Left unvalidated, a scope field
becomes forty spellings of the same three packages within a year, which is worse than no scope
because it looks queryable and isn't. Where the scope names line up with team boundaries they are a
shared vocabulary between teams, and that makes them a contract with an owner and a change process
([ZFN-47](/zfn/47-govern-the-contract-between-teams/)) rather than a free-text field.

### `BREAKING CHANGE:` is the part that has to be honest

This is where the format stops being cosmetic. If the release pipeline derives the version from the
log, a missing breaking marker does not produce a slightly untidy changelog — it publishes an
incompatible change as a **patch release**, carrying the confident assertion that it is safe to take
automatically. That is a worse outcome than choosing versions by hand, because hand-chosen versions
fail visibly and in front of a person.

So the breaking marker is the one part of a commit message a reviewer genuinely has to check, and it
is not delegable to the linter — no tool can tell from a diff whether a caller's assumption broke.
The body must say what breaks and what a consumer does about it, because that text becomes the
migration note in the changelog whether or not anyone writes one later.

### Enforce mechanically, never socially

Run [commitlint](ref:commitlint) as a `commit-msg` hook so authors get the error in the second it
happens, and again as a **blocking CI check**, because hooks are opt-in, aren't installed on a fresh
clone, and don't run for web edits or for every agent.

Then the step almost everyone gets wrong: **if you squash-merge, lint the pull request title.** The
squashed commit takes its message from the PR title, so a branch of immaculately linted commits is
collapsed into one unlinted message and the linted ones are discarded. You end up enforcing the
convention on exactly the commits that don't survive.

A convention enforced by code review is a convention that decays, because it decays one tired
reviewer at a time and there is never a good moment to block a merge over a prefix.

### Write the format into the agent instructions

Since agents author most commits now, put the type list, the legal scopes, an example, and the
breaking-change rule in whatever file the repository uses for agent instructions. It costs ten lines
and it is the single highest-compliance intervention available, because a documented format is the
one thing a model follows perfectly.

### The body is still the whole point

The header tells a reader what *kind* of change this is. Only the body tells them **why**, and why is
precisely the part that cannot be reconstructed from the diff. State the reason, and state the
alternative you rejected — the rejection is usually the more valuable half. Where the reasoning is
big enough to have been written down properly, cite the decision record in a footer rather than
paraphrasing it ([ZFN-1](/zfn/1-engineering-decision-records/)); the commit should point at the
argument, not re-run it. [Tim Pope's post](ref:tpope) is still the best short guidance on the prose
half, and it predates all of this.

Using an LLM to draft that prose is fine and needs no disclaimer — the obligation is that you read it
and can defend it, exactly as with anything else published under your name
([ZFN-26](/zfn/26-llm-assisted-content-cosign/)).

> [!aside]
>
> The thing that converted me wasn't tooling. It was doing archaeology on a payments path, following
> `git blame` back to the commit that introduced a condition nobody could explain, and finding the
> message `fix`. The author had left years earlier. The linked pull request 404'd — that forge had
> been migrated twice since. The condition turned out to be correct and important, and it took most
> of a day to prove it rather than the thirty seconds it would have taken to read one honest
> paragraph. Nobody was careless. The message was written in ninety seconds by someone who had no way
> to know that this one, out of their nine thousand commits, was the one that would matter.

## Consequences

**Easier:**

- Changelogs and version bumps are **derived** rather than assembled by hand before a release, which
  means they stop being a release-day task that gets skipped under pressure.
- `git log --grep` becomes an actual query interface. "Every breaking change to this package in the
  last year" is a one-liner instead of a reading exercise.
- Reviewers get the author's claim about intent *before* reading the diff, which is the right order
  and frequently the thing that reveals the diff doesn't match the claim.
- Regression hunting narrows fast — filtering `fix` and `feat` away from `chore` and `docs` cuts the
  search space before you start bisecting.
- Agents write consistent messages and can answer questions from the history, rather than adding to
  a pile of prose only a human can skim.

**Harder:**

- It is ceremony, and on a one-character typo fix the ceremony genuinely costs more than the change.
  I pay it because the value is entirely in the consistency, and a convention with defensible
  exceptions is one that erodes.
- The type taxonomy invites bikeshedding that produces no value whatsoever. It has to be capped by
  decree, early, by someone willing to be arbitrary.
- **A format is not a message, and this is the failure mode I'd watch for.** A repository can be one
  hundred percent conventional and still say nothing — `chore: updates`, `fix: fixes`, forever. That
  is arguably worse than obviously-scruffy history, because it *looks* rigorous, so nobody notices
  the information isn't there. The linter can only check the envelope; the body is unenforceable and
  it's the part that matters.
- Derived versions are only as truthful as the breaking markers, and a missed marker produces a
  confidently wrong release rather than a visible mistake.
- Retrofitting doesn't work backwards. History before the adoption date is unparseable, so every tool
  that reads the log has a start date and you live with the seam permanently.
- A format an agent follows perfectly says nothing about whether it understood the change it is
  describing ([ZFN-28](/zfn/28-llm-brute-force-prs-understanding/)). Well-formed messages on
  un-understood changes are a real risk, and this note does not address it.

**New obligations:**

- CI enforces the format on commits **and** on pull request titles wherever squash-merging is in use.
- The scope vocabulary needs an owner and a way to change it, or it silently becomes free text.
- Reviewers own the breaking-change marker specifically — the one field no linter can verify.
- The repository's agent instructions carry the format, and get updated when it changes.
- Someone has to say, out loud and more than once, that `chore: updates` passes the linter and still
  isn't acceptable.

## References

- [Conventional Commits 1.0.0](ref:conventionalcommits) — the specification itself; short enough to
  read in full before adopting it.
- [Semantic Versioning 2.0.0](ref:semver) — the compatibility promise the commit types exist to
  derive.
- [Angular commit message guidelines](ref:angular) — the origin of the type vocabulary, and a large
  project's day-to-day application of it.
- [Tim Pope, *A Note About Git Commit Messages*](ref:tpope) — the prose half, which Conventional
  Commits deliberately leaves unspecified.
- [commitlint](ref:commitlint) — hook and CI enforcement, including against a pull request title.
- [ZFN-30](/zfn/30-use-standards-dont-reinvent/) and [ZFN-45](/zfn/45-read-the-standards/) — a
  specification already exists for this; use it rather than growing a house dialect.
- [ZFN-51](/zfn/51-design-the-request-envelope-first/) — the envelope-and-payload split this applies
  to a commit message.
- [ZFN-1](/zfn/1-engineering-decision-records/) — where the reasoning lives when it is too big for a
  commit body; cite it in a footer.
- [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) — the same instinct applied to APIs: define
  the shape once, generate rather than improvise.
- [ZFN-47](/zfn/47-govern-the-contract-between-teams/) — why a scope vocabulary shared across teams
  needs governing.
- [ZFN-26](/zfn/26-llm-assisted-content-cosign/) — drafting a commit message with an LLM needs no
  disclaimer, only someone who read it.

## Changelog

- **2026-08-02**: First published as a Field Note.
