diff --git a/CLAUDE.md b/CLAUDE.md index 7caa9c5..22c4feb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `` 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/.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-/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `` 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/.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-.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. diff --git a/planning/README.md b/planning/README.md index 87a142f..d2958c3 100644 --- a/planning/README.md +++ b/planning/README.md @@ -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/-.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/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -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 diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md new file mode 100644 index 0000000..940fb37 --- /dev/null +++ b/planning/_templates/decision.md @@ -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. diff --git a/planning/decisions/2026-06-23-metrics-recorders-not-unified.md b/planning/decisions/2026-06-23-metrics-recorders-not-unified.md new file mode 100644 index 0000000..7b3e2ae --- /dev/null +++ b/planning/decisions/2026-06-23-metrics-recorders-not-unified.md @@ -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. diff --git a/planning/index.py b/planning/index.py index 80ef1f4..d38dffd 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,11 +1,12 @@ # 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 @@ -13,6 +14,7 @@ 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",)), @@ -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 "—" @@ -70,11 +87,11 @@ 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", ""), @@ -82,12 +99,16 @@ def render(bundles: list[dict[str, str]]) -> str: ) 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