Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Workflow

Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. `summary:` is written when the change is created (it is the change's one-liner); the implementing PR then sets `status: shipped`, fills `pr:` and `outcome:` in-branch, and promotes its conclusions into the affected `architecture/<capability>.md` **in the same PR, alongside the code** — the only ship-time step (there is no folder move), reviewed in the implementing diff rather than applied after merge; that hand-edit is what keeps `architecture/` true. See [`planning/README.md`](planning/README.md) for the conventions (run `just index` for the change listing) and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.
Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. `summary:` is written when the change is created (it is the change's one-liner); the implementing PR then sets `status: shipped`, fills `pr:` and `outcome:` in-branch, and promotes its conclusions into the affected `architecture/<capability>.md` **in the same PR, alongside the code** — the only ship-time step (there is no folder move), reviewed in the implementing diff rather than applied after merge; that hand-edit is what keeps `architecture/` true. A design decision taken **without** a code change — especially a candidate **rejected** with a load-bearing reason (e.g. an architecture-review suggestion we declined) — is recorded as `planning/decisions/YYYY-MM-DD-<slug>.md` (a `decision.md` template, frontmatter `status: accepted|superseded`), each with a **Revisit trigger** so future reviews don't re-litigate it. See [`planning/README.md`](planning/README.md) for the conventions (run `just index` for the change + decision listing) and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.

**Spec** (`design.md`) captures the *thinking* — why we are doing this, what the design is, what trade-offs were considered, what is out of scope. Written before code; rarely revised after merge. **Plan** (`plan.md`) captures the *sequencing* — the ordered checklist of tasks an executor (human or agent) walks. References the spec for the "why"; never re-explains it. **`architecture/`** captures the *invariants* of shipped systems — the living truth, promoted in the change's implementing PR alongside the code. A plan paragraph that would still read correctly with all task numbers and checkboxes removed is design content and belongs in the spec.

Expand Down
20 changes: 14 additions & 6 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ into `design.md` + `plan.md`.
- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope).
- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist).
- **`change.md`** — both, condensed, for the lightweight lane.
- **`decisions/<YYYY-MM-DD>-<slug>.md`** — one file per design decision taken
(especially options *rejected*), each with a revisit trigger, so reviews don't
re-litigate them; listed by `just index`.
- **`releases/<semver>.md`** — per-release user-facing notes.
- **`audits/<date>-<slug>.md`** — findings from a code/docs/bug-hunt sweep;
spawns fix changes.
Expand All @@ -65,21 +68,26 @@ Templates live in [`_templates/`](_templates/).

`design.md` / `change.md`: `status` (draft|approved|shipped|superseded),
`date`, `slug`, `summary` (single line), `supersedes`, `superseded_by`, `pr`,
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. Files in
`architecture/` carry **no** frontmatter — living prose, dated by git.
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. `decisions/*.md`:
`status` (accepted|superseded), `date`, `slug`, `summary`, `supersedes`,
`superseded_by`, `pr`. Files in `architecture/` carry **no** frontmatter —
living prose, dated by git.

## Index

The change listing is **generated**, not maintained — run `just index` to
print it (grouped by `status`: In progress / Shipped / Superseded). The
frontmatter in each bundle is the single source of truth; there is no
committed copy to drift.
The listing is **generated**, not maintained — run `just index` to print it:
changes grouped by `status` (In progress / Shipped / Superseded), then
decisions newest-first. The frontmatter in each bundle / decision file is the
single source of truth; there is no committed copy to drift.

## Other

- **[`architecture/`](../architecture/)** at the repo root — the living
capability truth (relay, timers, dlq, drain, metrics, test broker). This is
the promotion target on every ship.
- **[decisions/](decisions/)** — design decisions taken (and alternatives
rejected), each with a revisit trigger, so reviews don't re-litigate them;
indexed by `just index`.
- **[audits/](audits/)** — findings reports (2026-06-12 code + docs audits).
- **[lint-suppressions.md](lint-suppressions.md)** — repo-specific extra (not
part of the portable core): audit of `noqa` / `ty: ignore` directives and
Expand Down
26 changes: 26 additions & 0 deletions planning/_templates/decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
status: accepted # accepted | superseded
date: YYYY-MM-DD
slug: my-decision
summary: One line — shown in `just index`.
supersedes: null
superseded_by: null
pr: null # PR/commit where the decision was made or recorded
---

# One-line capitalized title

**Decision:** What was decided, in a sentence.

## Context

Why this came up; the options that were on the table.

## Decision & rationale

The call and why — including why the alternatives were rejected. Enough that a
future explorer doesn't re-litigate it.

## Revisit trigger

The concrete signal that should reopen this decision.
68 changes: 68 additions & 0 deletions planning/decisions/2026-06-23-metrics-recorders-not-unified.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
status: accepted
date: 2026-06-23
slug: metrics-recorders-not-unified
summary: Keep PrometheusRecorder and OpenTelemetryRecorder as separate hand-written event switches — do not factor them behind a shared data-driven event→metric table.
supersedes: null
superseded_by: null
pr: 112
---

# Metrics recorders stay separate; no shared event→metric table

**Decision:** The two `MetricsRecorder` adapters (`metrics/prometheus.py`,
`metrics/opentelemetry.py`) keep their own hand-written `__call__` event switches.
We will **not** introduce a declarative event→metric table both adapters consume.

## Context

The 2026-06-23 architecture review (candidate #4) flagged the two adapters as
duplication: both `__call__` methods branch on the same event names (`fetched`,
`dispatched`, `acked`/`nacked_*`, `lease_lost`, `dlq_written`, `drain_timeout`,
`published`) in ~80-line switches, and proposed a shared table mapping each event to
`(metric kind, name, label set)` so the vocabulary lives once. The review marked it
*Speculative* and noted the emit primitives differ.

## Decision & rationale

On inspection the shared surface is only the **event-name dispatch ladder** plus the
tag keys read. The per-event bodies are irreducibly backend-specific:

- `dispatched` → Prometheus does three operations (received counter + size histogram +
in-process **gauge.inc**); OpenTelemetry is a **no-op**.
- `acked`/`nacked_*` → Prometheus uses *separate labeled counters* (`processed_total`,
`terminal_reason`, `processed_exceptions`) plus a gauge `.dec`; OTel folds `reason` /
`exception` into **attributes** on one shared duration histogram.
- `lease_lost` → Prometheus *reuses* `processed_total{status=error}` and adds a
`lease_lost` counter; OTel only touches `lease_lost`.
- `fetched` → Prometheus encodes `non_empty` as a label; OTel has no such attribute.
- `published` → different count-vs-error gating per backend.

A `(metric kind, name, label set)` table cannot express this: the same event maps to a
*different number of instruments* per backend, the same tag is a *labeled counter* in
Prometheus but an *attribute* in OTel, and Prometheus has cross-event reuse OTel does
not. A table would need a per-backend escape hatch on nearly every row.

**Deletion test:** deleting a hypothetical shared table pushes the per-event bodies back
exactly where they are now — only the trivial dispatch ladder disappears. That is a
*shallow* abstraction; building it would worsen locality (a reader of one metric would
bounce between the table and per-backend overrides). The two-seam split (recorder vs
native middleware) is deliberate depth and already documented in
`architecture/metrics.md`; the per-adapter switches are backend-specific *translation*,
not duplication worth abstracting.

The review's real underlying worry — "add an event, forget one adapter, no test catches
it" — is a parity concern, not a dedup one. We are **not** adding a parity contract test
either, for now: the event vocabulary is stable/additive (changes are rare), each adapter
has thorough independent tests (23 + 17), and a new event is emitted from a new call site
the author touches anyway. If that calculus changes, see the revisit trigger.

## Revisit trigger

Reopen if **either**:

- the event vocabulary starts changing often (≥3 new events within a release cycle), at
which point a machine-readable `EVENTS` registry + a parity test that feeds each event
to both adapters (the candidate-#1 "co-verify, don't share" pattern) becomes worth it; or
- a third `MetricsRecorder` adapter is added (StatsD, etc.) — three hand-written switches
may shift the cost/benefit toward a shared dispatch skeleton with per-backend emit hooks.
43 changes: 32 additions & 11 deletions planning/index.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# ruff: noqa: INP001 # planning/ is not a Python package; this is a standalone script
"""
Generate the planning change index from bundle frontmatter.
Generate the planning index from frontmatter.

Run via ``just index``. Globs ``planning/changes/*/``, reads each bundle's
``design.md`` (falling back to ``change.md``) frontmatter, and prints a
Markdown listing grouped by lifecycle status to stdout. Never writes a file:
the listing is a query over the bundles, not a committed artifact.
Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's ``design.md``,
falling back to ``change.md``) and ``planning/decisions/*.md``, reads their
frontmatter, and prints a Markdown listing to stdout — changes grouped by lifecycle
status, then decisions newest-first. Never writes a file: the listing is a query over
the files, not a committed artifact.
"""

import pathlib
import sys


CHANGES_DIR = pathlib.Path(__file__).parent / "changes"
DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions"
GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("In progress", ("draft", "approved")),
("Shipped", ("shipped",)),
Expand Down Expand Up @@ -55,8 +57,23 @@ def load_bundles() -> list[dict[str, str]]:
return bundles


def load_decisions() -> list[dict[str, str]]:
"""Read frontmatter from every decision file under ``DECISIONS_DIR``."""
decisions: list[dict[str, str]] = []
if not DECISIONS_DIR.is_dir():
return decisions
for path in sorted(DECISIONS_DIR.glob("*.md")):
if path.name == "README.md" or path.name.startswith("_"):
continue
fields = parse_frontmatter(path.read_text(encoding="utf-8"))
fields["path"] = f"decisions/{path.name}"
fields["name"] = path.stem
decisions.append(fields)
return decisions


def format_row(bundle: dict[str, str]) -> str:
"""Render one bundle as a Markdown list item."""
"""Render one bundle or decision as a Markdown list item."""
slug = bundle.get("slug", "?")
path = bundle.get("path", "")
pr = bundle.get("pr") or "—"
Expand All @@ -70,24 +87,28 @@ def format_row(bundle: dict[str, str]) -> str:
return line


def render(bundles: list[dict[str, str]]) -> str:
"""Render the full grouped Markdown listing."""
out = ["# Change index", "", "_Generated by `just index` — do not edit._", ""]
def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str:
"""Render the full Markdown listing: changes by status, then decisions."""
out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""]
for title, statuses in GROUPS:
out += [f"## {title}", ""]
out += [f"### {title}", ""]
rows = sorted(
(b for b in bundles if b.get("status") in statuses),
key=lambda b: b.get("name", ""),
reverse=True,
)
out += [format_row(b) for b in rows] if rows else ["_None._"]
out.append("")
out += ["## Decisions", ""]
decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True)
out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"]
out.append("")
return "\n".join(out).rstrip() + "\n"


def main() -> int:
"""Print the listing to stdout."""
sys.stdout.write(render(load_bundles()))
sys.stdout.write(render(load_bundles(), load_decisions()))
return 0


Expand Down