ADR-133: Changelog entry schema and PR-body block format
Status: Proposed Date: 2026-05-05
Context
Sub-ticket of EPIC warp#1358 — per-project changelog + /changes pages + PR title-format ticket-ID contract. Today the fleet has rich coordination metadata (warp tickets), rich review metadata (3-persona review verdicts on PRs), and rich version metadata (release tags). What it does not have is a structured, per-project, end-user-readable record of what shipped — the substrate that drives a /changes page on a product surface, that drives release-notes generation, and that lets a factory-events emitter (sister: warp#1316) reason about user-visible velocity.
The gap manifests three ways:
- A user landing on a product's
/changespage today sees git log noise (refactors, internal plumbing, dependency bumps) interleaved with the rare user-visible change. The signal-to-noise is so low the page is unbuilt. - Release-notes generation today is "scrape the merge commit subjects since the last tag" — every PR title that doesn't follow a convention becomes a sentence of jargon to an end user.
- Fleet-velocity event-log (warp#1316) wants to emit
feature_shipped/bug_fixedevents with end-user-readable summaries — but no upstream substrate produces those summaries; the emitter would have to invent them.
Phase 1 of the EPIC (capture mechanics — sub-tickets C1.1–C1.5) implements the substrate: a <repo>/.changelog/entries.jsonl file per project, populated at PR-merge time from a ## Changelog Entry block in the PR body. Phase 2 renders /changes pages from it. Phase 3 emits fleet events.
For Phase 1 to implement against a stable contract — and for Phases 2 and 3 to consume the substrate without re-litigating shape — this ADR locks the schema decisions now, before any code lands. The structural cost of getting the schema wrong (every reader has to re-parse, every existing entry needs a migration) is much higher than the structural cost of locking it before the first writer ships.
Decision
A changelog entry is a single JSON object that obeys the schema below. Entries are appended one-per-line to <repo>/.changelog/entries.jsonl at PR merge time. Each entry mirrors a ## Changelog Entry YAML block in the merged PR's body. The block is the author-facing surface; the JSONL line is the machine-facing surface; the two carry the same fields.
entries.jsonl schema
| Field | Type | Required | Format / constraints |
|---|---|---|---|
date |
string | yes | ISO-8601 calendar date, UTC, YYYY-MM-DD. Date the PR merged to default branch. |
version |
string | yes | The semver tag the PR shipped under. If the project has not tagged since the PR merged, the writer fills unreleased; the next release-tag job rewrites in place. |
pr |
integer | yes | The merged PR number. Repo-scoped — the file lives under <repo>/.changelog/, so the repo is implied by location. |
warp_ticket |
string | conditional | warp#NNNN or null. Required when the PR title contains a warp#NNNN reference (per the title-format contract — separate sub-ticket of warp#1358). Null when the PR is genuinely ticket-less (typo fixes, --no-ticket work). |
category |
string | yes | One of: feature | fix | improvement | breaking. Closed enum. New categories require an ADR amendment. |
user_facing |
boolean | yes | true when the change alters something a non-developer end user can observe (UX, feature, visible bug, copy change, perf user-can-feel). false for refactors, internal tooling, infra, dependency bumps, doc-only changes. See user_facing semantics below. |
summary |
string | yes | One sentence, end-user-readable. ≤ 140 characters. No internal jargon. No bare ticket numbers. Ends without period (renderer adds punctuation). See summary constraints below. |
tickets |
array of string | optional | Multi-ticket case: when one PR closes multiple warp tickets, list all of them here. The single warp_ticket field still holds the primary ticket (the one in the PR title). Renderers treating tickets as canonical fall back to [warp_ticket] when absent. |
Every other key is reserved. Writers must not invent fields outside this list; readers must ignore unknown keys (forward-compat — see Consequences).
PR-body block format
A ## Changelog Entry heading followed by a fenced YAML code block with the same fields. As literal markdown source:
[H2] Changelog Entry
```yaml
category: feature
user_facing: true
summary: Sign-up form now accepts magic-link email instead of password
warp_ticket: warp#1234
```
The block's fields are limited to category, user_facing, summary, and (optionally) tickets. The date, version, pr, and warp_ticket fields of the JSONL line are derived by the merge-time writer from PR metadata — author does not write them. (warp_ticket may appear in the YAML block as a manual override when the PR title parser cannot resolve it; otherwise omit.)
The block is detected by the fenced YAML code block immediately following an H2 of exact text Changelog Entry. Any other heading text, or YAML outside a fenced block, is not a changelog entry.
user_facing semantics
user_facing: true when any of the following is true:
- The change is visible on a product surface (web page, CLI output, API response shape, email content) to a non-developer end user.
- The change adds, removes, or alters a feature an end user can interact with.
- The change fixes a bug an end user could observe (broken button, wrong number, slow page).
- The change alters copy, layout, or visual design that an end user sees.
user_facing: false when all of the following are true:
- The change is invisible to a non-developer end user (refactor, dependency bump, internal API change with no surface impact, test, CI, doc, infra).
- An end user cannot observe a difference before/after.
When in doubt, the bar is "would the end user, given diff before / diff after, notice?" If yes: true. If no: false. This is the field ADR-115's persona reviewer (Marcus, structural) checks against the PR shape.
category enum semantics
feature— a new capability the end user (or developer-as-user, for library projects) did not have before.fix— corrects a bug. The user-visible-ness is independent of category — afixcan beuser_facing: falseif the bug was internal-only.improvement— alters an existing capability without adding a new one. Performance, ergonomics, design polish.breaking— a change that requires action by an existing user / consumer to remain working. Implies a major-version bump per the project's semver policy. Alwaysuser_facing: true(a breaking change the user can't observe isn't breaking).
summary constraints
- One sentence. No multi-sentence descriptions; if the change needs more, link the PR (the renderer auto-links from
pr). - ≤ 140 characters.
- End-user-readable. Subject is a thing the user knows ("sign-up form", not "AuthHandler"). Verb is what changed ("now accepts", not "wired up").
- No internal jargon. No code identifiers, no class names, no module paths.
- No bare ticket numbers (
warp#1234already has its own field). - No emoji unless the project's renderer canonicalises them (most don't).
- Present tense, active voice, end-user as implicit subject.
- No trailing period. The renderer composes punctuation in context (lists, sentences, tooltips).
Multi-ticket PRs
When a single PR closes multiple warp tickets:
warp_ticket(singular): the primary ticket — the one referenced in the PR title (per the title-format contract). This is what release-notes group by.tickets(array): every warp ticket the PR closes, including the primary. Order: primary first, then closure order.- One JSONL entry per PR (not per ticket). The PR is the unit of shipping; the tickets are coordination metadata.
If a PR genuinely changes two unrelated user-facing things (rare; smell of an over-scoped PR), the author may emit two ## Changelog Entry blocks; the writer emits two JSONL lines with the same pr field. The reviewer should question the PR scope before accepting two-block PRs.
Backfill rules
Backfilling pre-ADR PRs into entries.jsonl is partially recoverable:
- Recoverable from PR metadata:
date(merge timestamp),pr(number),warp_ticket(parse from title — relies on the title-format contract holding historically; for PRs predating that contract, leave null). - Recoverable from release tags:
version(the tag containing the merge SHA). - Lossy:
user_facing(no historical signal — the field didn't exist),category(best-guess from title prefixfeat:/fix:/chore:),summary(best-guess from title with jargon stripped).
Backfilled entries carry an extra backfilled: true key (the one exception to the "no extra keys" rule, scoped to backfill jobs only). Renderers may de-emphasise backfilled entries (greyed, "imported from history" badge, hidden behind "show all"). The exception is local to backfill; future writers must not set it.
Forward-compat
Adding new fields to the schema is allowed without an ADR amendment if:
- The new field is optional (renderers without the field still produce correct output).
- The new field has a documented default (so a reader treating the absent field as "default" produces correct output).
- The new field is added to a single source of truth (this ADR's table, amended in place; the immutability rule is relaxed for the schema table specifically — see Consequences).
Removing a field, narrowing a field's type, or renaming a field requires a new ADR that supersedes this one. Existing entries are not rewritten — readers tolerate the old shape forever.
The closed category enum is the one exception: adding a category requires an ADR amendment because renderers switch on it.
Sample entry
A line from <repo>/.changelog/entries.jsonl:
{"date":"2026-05-05","version":"v0.4.2","pr":620,"warp_ticket":"warp#1359","category":"improvement","user_facing":false,"summary":"Locked changelog entry schema and PR-body block format for downstream phases"}
Note user_facing: false because this PR ships an ADR (engineering-internal); a sample of user_facing: true for contrast:
{"date":"2026-05-04","version":"v1.7.0","pr":611,"warp_ticket":"warp#1290","tickets":["warp#1290","warp#1291"],"category":"feature","user_facing":true,"summary":"Direction page now shows the next-three-actions panel on first load"}
Sample PR-body block
The block is an H2 heading whose text is exactly Changelog Entry, followed by a fenced YAML code block (single-tag yaml) carrying the four allowed fields. A copy-pasteable feature example:
# (preceded in the PR body by an H2 heading: "Changelog Entry")
category: feature
user_facing: true
summary: Direction page now shows the next-three-actions panel on first load
A breaking change with multiple tickets — same shape, plus the optional tickets array:
# (preceded in the PR body by an H2 heading: "Changelog Entry")
category: breaking
user_facing: true
summary: API endpoints now require an X-Project-Id header on every request
tickets:
- warp#NNNN
- warp#NNNN
An internal/infra PR — user_facing: false, no tickets array:
# (preceded in the PR body by an H2 heading: "Changelog Entry")
category: improvement
user_facing: false
summary: Build pipeline now caches Go module downloads across CI runs
Consequences
Easier
- Phase 1 (capture mechanics — C1.1–C1.5) implements against a frozen schema. No re-litigation of fields per sub-ticket.
/changespage renderer (Phase 2) reads JSONL with a known shape — no schema-discovery code, no "what does this entry look like" branching.- Fleet-events emitter (warp#1316, Phase 3) maps
entries.jsonlrows tofeature_shipped/bug_fixedevents 1:1 withcategoryanduser_facingas the routing keys. - PR authors face a small, learnable surface: H2 + fenced YAML, four fields. The block is editable in the GitHub UI.
- Persona reviewers (per ADR-115) have a structural target to check: the YAML block exists, its fields validate,
user_facingmatches the PR shape Marcus reviewed. - Backfill is bounded and auditable. The
backfilled: trueflag means renderers and consumers can distinguish reconstructed entries from authored ones.
Harder
- PR authors must add the YAML block. New friction. Mitigated by
gyrum-start-workscaffolding (the block goes in the draft PR body at branch creation; the author edits in place rather than appending). - The
user_facingflag is judgement-dependent — different authors will draw the line differently. Mitigated by the user_facing semantics table above and by Marcus's review check (ADR-115); over time, the corpus of accepted entries becomes the canonical answer. - The closed
categoryenum will pinch — sooner or later someone wantssecurityordeprecation. The ADR-amendment cost is real but bounded; the alternative (open enum) breaks renderer switches. - Schema evolution requires reader tolerance for unknown keys. Renderers that crash on unknown keys break on the first forward-compat addition; CI must include a "renderer survives an entry with an unknown key" test.
- Multi-ticket PRs and two-block PRs add edge cases the writer must handle. The bar above (two-block PRs are a smell, single primary ticket via title-format contract) keeps the common case simple.
What we sign up to operate
- The merge-time writer (sub-ticket of EPIC warp#1358 — Phase 1) — emits one JSONL line per merged PR, derives
date/version/pr/warp_ticketfrom PR metadata, copiescategory/user_facing/summary/ticketsfrom the YAML block. - A schema validator that runs on the YAML block at PR-author time (failing closed: malformed block blocks merge) — and at PR-merge time (failing closed: malformed block aborts the writer).
- The renderer-tolerates-unknown-keys CI test in any project that consumes
entries.jsonl. - The backfill job (one-shot per project) — reads PR metadata + release tags, emits backfilled entries with the
backfilled: trueflag. - The amendment process for schema additions: PR against this ADR's table, regression-suite re-run for any consuming renderer.
Soft-warn fallback (sister-link to ADR-110)
The default Phase-1 behaviour is hard-rejection: a PR with no ## Changelog Entry block, or with a malformed block, fails the gate and cannot merge. If author friction proves prohibitive, the gate can drop to observe-and-file per ADR-110: the gate passes, an observer Claude step files a warp ticket against the PR's repo flagging the missing/malformed block, and a periodic curation pass backfills. This is the dominant-usage pattern documented in ADR-110 and is the path of least resistance if the hard gate triggers a friction revolt. The choice is made by Phase 1 implementation — this ADR locks the schema, not the enforcement strength.
Principle-aware reviewer (sister-link to ADR-115)
The user_facing flag is the field most likely to be wrong (or worse, lazily set). Per ADR-115, persona reviewers fetch applicable principles before reviewing; this ADR's user_facing semantics section becomes such a principle. Marcus (structural) compares the flag's value to the diff shape — a diff that touches src/routes/** or src/lib/components/** with user_facing: false is a flagged finding; a diff that's pure infra/CI with user_facing: true is the same. The reviewer quotes the principle in the finding ("per ADR-133, user_facing: true when the change is visible on a product surface; this diff touches src/routes/login — the flag should be true").
Alternatives considered
Generate entries entirely from PR titles + labels — no PR-body block, no author burden. Lost: the
summaryfield is the whole point of the substrate (PR titles are jargon-laden), anduser_facinghas no signal in PR titles. Ruled out: the substrate's value is in the curated summary, which has to be authored.A separate file per entry (
<repo>/.changelog/entries/<pr>.json) instead of JSONL. Lost: per-file overhead (Git churn, directory bloat, no cheap "tail the latest 50") with no structural gain. JSONL is the canonical append-only shape for this kind of substrate (sister-shape:~/.gyrum/findings/findings.jsonl).Open
categoryenum ("any string the author wants"). Lost: renderers switch on category for grouping, sorting, and visual treatment; an open enum forces a fallthrough bucket and dilutes the page's structure. The amendment-per-new-category cost is the right shape — the conversation about "do we really need a new category" is the conversation we want to have.No
user_facingflag — render every entry on/changes. Lost: the page becomes git log noise again (the current state). The whole reason to add the substrate is to filter to user-visible-only.Free-form Markdown changelog block instead of fenced YAML. Lost: machine-parseability. The
entries.jsonlline is derived from the block at merge time; the block must parse deterministically. Markdown formatting is not a parser-friendly substrate.Defer the schema until Phase 1 implementation needs it. The path that always loses. Phase 1 sub-tickets would each make slightly different field assumptions; the first writer's choices would freeze the schema by accident; the second writer would discover the gap and patch it; by Phase 2, three readers would each support a different superset. Ruled out: this is exactly why the EPIC carved out C0.1.
Supersedes: none Superseded by: {{leave blank until a later ADR reverses this one}}