Decisions

ADR-133: Changelog entry schema and PR-body block format

Sub-ticket of EPIC [warp#1358](https://warp.gyrum.ai/items/1358) — per-project changelog + `/changes` pages + PR title-format ticket-ID contract. Today the fleet has rich coordination metadata (warp tickets), rich…

#133

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 /changes page 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_fixed events 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 — a fix can be user_facing: false if 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. Always user_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#1234 already 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 prefix feat: / 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.
  • /changes page 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.jsonl rows to feature_shipped / bug_fixed events 1:1 with category and user_facing as 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_facing matches the PR shape Marcus reviewed.
  • Backfill is bounded and auditable. The backfilled: true flag 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-work scaffolding (the block goes in the draft PR body at branch creation; the author edits in place rather than appending).
  • The user_facing flag 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 category enum will pinch — sooner or later someone wants security or deprecation. 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_ticket from PR metadata, copies category/user_facing/summary/tickets from 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: true flag.
  • 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 summary field is the whole point of the substrate (PR titles are jargon-laden), and user_facing has 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 category enum ("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_facing flag — 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.jsonl line 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}}