diff --git a/CLAUDE.md b/CLAUDE.md index a861563..8e793dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,8 @@ The CI `DB_DSN` format: `postgresql+asyncpg://postgres:postgres@localhost:5432/p Planning follows [`planning/README.md`](planning/README.md) — its **Quick path** is the authoritative convention for making a change (choose a lane, create a -bundle under `planning/changes/`, ship the `architecture/` promotion in the same -PR). Run `just check-planning` (also wired into `just lint-ci`) before pushing. +change file under `planning/changes/`, ship the `architecture/` promotion in the +same PR). Run `just check-planning` (also wired into `just lint-ci`) before pushing. ## Architecture diff --git a/architecture/README.md b/architecture/README.md index 4d0e73d..c1a6595 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -30,5 +30,5 @@ down. ## Promotion rule Shipping a change hand-edits the affected capability file(s) here to match the -new reality, in the same PR as the code. The change bundle stays in place under +new reality, in the same PR as the code. The change file stays in place under [`../planning/changes/`](../planning/changes/) — no folder move. diff --git a/planning/.convention-version b/planning/.convention-version index 9084fa2..227cea2 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -1.1.0 +2.0.0 diff --git a/planning/README.md b/planning/README.md index 18b114a..226b63e 100644 --- a/planning/README.md +++ b/planning/README.md @@ -18,25 +18,22 @@ repo-local. **1. Choose a lane — first matching rule wins:** 1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** - (`design.md` + `plan.md`) + cross-cutting or multi-file · non-trivial test design → **Full** (design template) 2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no bundle, conventional + mechanical rename · single-line config → **Tiny** (no change file, conventional commit) 3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** - (`change.md`) + no public-API change · one straightforward test → **Lightweight** (change template) -Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane -splits into `design.md` + `plan.md`. +Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. -**2. Create the bundle** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded -intra-day counter. Copy the matching template from +**2. Create the change file** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded +intra-day counter — copied from the matching template (design or change) in [`_templates/`](_templates/). **3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the bundle's `summary:` to the +`architecture/.md`, finalize the change file's `summary:` to the realized result, and run `just check-planning` before pushing. ## Conventions @@ -52,14 +49,14 @@ realized result, and run `just check-planning` before pushing. - **`architecture/` (repo root) — the present.** One file per capability, plus a single `glossary.md` (the ubiquitous language); living prose, updated in the same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, +- **`planning/changes/` — the past-and-pending.** One file per change, kept in place after ship. A change **promotes** its conclusions into the affected `architecture/.md` by hand **in the implementing PR, alongside the code** — the edit rides in the same diff and is reviewed with it, never applied as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the bundle stays in `changes/` as the *why*. +true; the change file stays in `changes/` as the *why*. ### Glossary @@ -81,9 +78,9 @@ Keep it a glossary, not a spec — no implementation detail. A change that introduces or sharpens a term updates `glossary.md` in the same PR, the same way a behavior change promotes into a capability file. -### Change bundles +### Change files -A change is a folder `changes/YYYY-MM-DD.NN-/`: +A change is a file `changes/YYYY-MM-DD.NN-.md`: - `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. @@ -92,25 +89,44 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: `summary` is written when the change is created (the intent one-liner) and **finalized at ship** to state the realized result — set in the implementing PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no folder move. `date` and `slug` are never written — they are -read from the bundle's directory name. +bookkeeping, no file move. `date` and `slug` are never written — they are +read from the file name. ### Three lanes | Lane | Artifacts | Use when | |------|-----------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | | **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. +Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. + +### Plans are ephemeral + +The executable plan — task checklists, embedded code, commit sequences, +whatever the executor needs — is a working artifact, not history. Keep it out +of `changes/` and out of version control (git-ignored scratch, e.g. +`.superpowers/`). Once the change ships, the diff and the PR are the record +of execution; a committed plan duplicates them. `check-planning` rejects +anything in `changes/` that is not a flat change file. + +### Lean specs + +The change file is the single home of a change's rationale: + +- The PR body summarizes and links to the change file — it never restates it. +- Rejected alternatives live in `decisions/` and are referenced, not retold. +- Show a sketch when the design needs code; never the full diff-to-be. +- Delete template sections that don't apply — an empty section is ceremony. +- Most designs fit well under ~700 words; length must buy information. ### Artifacts at a glance -- **`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. +- **design template** — the spec: the *thinking* (why, design, trade-offs, + scope); the change file it produces is the single home of rationale (see + [Lean specs](#lean-specs)). +- **change template** — the condensed spec for the lightweight lane. - **`releases/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -124,11 +140,10 @@ Templates live in [`_templates/`](_templates/). ### Frontmatter -`date` and `slug` are **derived from the directory / file name** — never +`date` and `slug` are **derived from the file name** — never repeated in frontmatter. So: -- `design.md` / `change.md`: `summary` (single line) only. -- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `changes/*.md`: `summary` (single line) only. - `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional `supersedes` / `superseded_by`. - Files in `architecture/` carry **no** frontmatter — living prose, dated by git. @@ -140,7 +155,7 @@ only field the index renders. ## Index Run `just index` to print the change/decision index (newest-first) to stdout; -`just check-planning` validates bundle and decision shape (wired into +`just check-planning` validates change and decision shape (wired into `just lint-ci`). ## Other diff --git a/planning/_templates/change.md b/planning/_templates/change.md index d4c8962..5aa7e81 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -5,8 +5,8 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Change: One-line capitalized title **Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, split into -`design.md` + `plan.md`. +change, a single straightforward test. If it outgrows this, rewrite it from +the design template. ## Goal diff --git a/planning/_templates/design.md b/planning/_templates/design.md index d63e22d..17dbee1 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -4,6 +4,10 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Design: One-line capitalized title + + ## Summary One paragraph. What changes, at the level a reader needs to decide if this @@ -12,37 +16,23 @@ spec is worth reading in full. ## Motivation Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. +abstract complaints. ## Design -### 1. - What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. +can follow. Sketches and interface fragments welcome; never the full +diff-to-be. Reference rejected alternatives in `decisions/` instead of +retelling them. -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope +## Non-goals -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. +What is deliberately out of scope and (when nontrivial) why. One line each. ## Testing -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. +How we know it landed correctly. Be specific: the command and the expected +signal. ## Risk diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md deleted file mode 100644 index 132d720..0000000 --- a/planning/_templates/plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": " - ``` - ---- - -### Task 2: ... diff --git a/planning/changes/2026-06-26.01-retriable-error-seam/design.md b/planning/changes/2026-06-26.01-retriable-error-seam.md similarity index 100% rename from planning/changes/2026-06-26.01-retriable-error-seam/design.md rename to planning/changes/2026-06-26.01-retriable-error-seam.md diff --git a/planning/changes/2026-06-26.01-retriable-error-seam/plan.md b/planning/changes/2026-06-26.01-retriable-error-seam/plan.md deleted file mode 100644 index 81bb0a3..0000000 --- a/planning/changes/2026-06-26.01-retriable-error-seam/plan.md +++ /dev/null @@ -1,176 +0,0 @@ -# retriable-error-seam — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. Each bug/behaviour is TDD — -> failing test first. - -**Goal:** Move the retriable-error decision behind a pure -`is_retriable(exc) -> bool` seam in `db_retry/retriable.py`, tested in memory, -with `postgres_retry` as a thin consumer. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/retriable-error-seam` - -**Commit strategy:** Per-task commits. - ---- - -### Task 1: Pin the domain term (glossary) - -**Files:** -- Create: `architecture/glossary.md` - -Author `architecture/glossary.md` lazily with its first term, **Retriable -error**, using the `planning/_templates/glossary.md` shape. - -- [ ] **Step 1: Write the term** - - Entry: **Retriable error** — a PostgreSQL failure transient enough to retry - the operation unchanged: a serialization failure (`40001`) or a lost - connection (class `08`). `_Avoid_:` transient error, recoverable error. - No implementation detail. - -- [ ] **Step 2: Commit** - - ```bash - git add architecture/glossary.md - git commit -m "docs: add Retriable error to glossary" - ``` - ---- - -### Task 2: New `retriable.py` seam, TDD - -**Files:** -- Create: `tests/test_retriable.py` -- Create: `db_retry/retriable.py` - -Build the pure predicate test-first. - -- [ ] **Step 1: Write the failing matrix** - - `tests/test_retriable.py`: a `_make_dbapi_error(cause)` helper building a - `DBAPIError("...", None, orig)` whose `orig.__cause__ = cause`. Parametrized - cases per the spec's Testing section — 40001, 08000, an 08-subclass - (`ConnectionDoesNotExistError`), non-retriable `PostgresError`, re-wrapped - `RepositoryError.__cause__` chain, `__context__`-only link, a `__cause__` - cycle, and a bare non-DBAPI exception. Import `is_retriable` and - `RETRIABLE_ASYNCPG_ERRORS` from `db_retry.retriable`. - - Run: `uv run pytest tests/test_retriable.py` → fails (ModuleNotFound). - -- [ ] **Step 2: Write the module to green** - - `db_retry/retriable.py`: `RETRIABLE_ASYNCPG_ERRORS` tuple, private - `_is_retriable_link`, pure `is_retriable` with the cause/context walk + cycle - guard. No logger. - - Run: `uv run pytest tests/test_retriable.py` → all pass. No DB needed. - -- [ ] **Step 3: Commit** - - ```bash - git add db_retry/retriable.py tests/test_retriable.py - git commit -m "feat: extract is_retriable predicate into retriable.py" - ``` - ---- - -### Task 3: Rewire `retry.py` onto the seam - -**Files:** -- Modify: `db_retry/retry.py` - -Replace the private predicate with a thin logging wrapper over `is_retriable`. - -- [ ] **Step 1: Edit** - - Import `is_retriable` from `db_retry.retriable`. Add `_log_and_decide` - (preserves both debug lines verbatim). Point `retry=tenacity.retry_if_exception` - at it. Delete `_is_retriable_dbapi_error` and `_retry_handler`. Remove the now - unused `asyncpg` / `DBAPIError` imports if no longer referenced. - -- [ ] **Step 2: Verify lint + types** - - Run: `just lint-ci` → passes (ruff, ty, eof, planning). - -- [ ] **Step 3: Commit** - - ```bash - git add db_retry/retry.py - git commit -m "refactor: wire postgres_retry through is_retriable seam" - ``` - ---- - -### Task 4: Trim the integration suite - -**Files:** -- Modify: `tests/test_retry.py` - -Move the classification matrix to Task 2; keep two wiring proofs. - -- [ ] **Step 1: Edit** - - Reduce to two integration cases against the live engine: `40001` (retriable → - retries, assert attempt count) and `40002` (non-retriable → single call, - original `DBAPIError` reraised). Keep `test_postgres_retry_with_retries` - (per-callsite `retries=` override). Delete the full parametrized matrix and - the advanced-alchemy case (now covered in `test_retriable.py`). - -- [ ] **Step 2: Verify** - - Run: `just test` (Docker Postgres) → green. `just lint-ci` → passes. - -- [ ] **Step 3: Commit** - - ```bash - git add tests/test_retry.py - git commit -m "test: trim retry integration suite to wiring proofs" - ``` - ---- - -### Task 5: Promote architecture docs + finalize bundle - -**Files:** -- Create: `architecture/retriable.md` -- Modify: `architecture/retry.md`, `architecture/README.md` -- Modify: `planning/changes/2026-06-26.01-retriable-error-seam/design.md` (summary) - -- [ ] **Step 1: Write `architecture/retriable.md`** - - Document `is_retriable`, `RETRIABLE_ASYNCPG_ERRORS`, the cause-chain walk + - cycle guard, and why `40002` is excluded. No frontmatter. - -- [ ] **Step 2: Edit `architecture/retry.md`** - - Drop "What counts as retriable" / "Cause-chain walk"; replace with a one-line - pointer to `retriable.md` and note the `_log_and_decide` wiring. - -- [ ] **Step 3: Edit `architecture/README.md`** - - Add the `retriable.md` capability row; update the `retry.md` row so it no - longer claims the predicate. - -- [ ] **Step 4: Finalize `summary:` and verify planning** - - Set `design.md` `summary:` to the realized result. Run `just check-planning` - → `planning: OK`. - -- [ ] **Step 5: Commit** - - ```bash - git add architecture/ planning/ - git commit -m "docs: promote retriable capability into architecture/" - ``` - ---- - -### Task 6: Ship - -- [ ] **Step 1:** `just lint-ci` and `just test` both green. -- [ ] **Step 2:** Push `feat/retriable-error-seam`, open PR, watch CI. diff --git a/planning/changes/2026-06-26.02-retriable-test-hardening/change.md b/planning/changes/2026-06-26.02-retriable-test-hardening.md similarity index 100% rename from planning/changes/2026-06-26.02-retriable-test-hardening/change.md rename to planning/changes/2026-06-26.02-retriable-test-hardening.md diff --git a/planning/changes/2026-06-26.03-connection-plan-split/design.md b/planning/changes/2026-06-26.03-connection-plan-split.md similarity index 100% rename from planning/changes/2026-06-26.03-connection-plan-split/design.md rename to planning/changes/2026-06-26.03-connection-plan-split.md diff --git a/planning/changes/2026-06-26.03-connection-plan-split/plan.md b/planning/changes/2026-06-26.03-connection-plan-split/plan.md deleted file mode 100644 index 11543aa..0000000 --- a/planning/changes/2026-06-26.03-connection-plan-split/plan.md +++ /dev/null @@ -1,144 +0,0 @@ -# connection-plan-split — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. New behaviour/tests are TDD — -> failing test first. - -**Goal:** Extract a pure `build_connection_plan(url) -> ConnectionPlan` out of -`build_connection_factory`, leaving a thin failover loop with a single `_connect` -helper — parsing/ordering testable without mocking `asyncpg.connect`, connect -call written once, behaviour unchanged. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/connection-plan-split` - -**Commit strategy:** Per-task commits. - ---- - -### Task 1: Pure plan + mock-free tests, TDD - -**Files:** -- Modify: `tests/test_connection_factory.py` (add plan tests) -- Modify: `db_retry/connections.py` (add `ConnectionPlan` + `build_connection_plan`) - -Introduce the pure plan, built test-first. Do NOT yet rewire -`build_connection_factory` (Task 2) — add the new code alongside the existing -closure. - -- [ ] **Step 1: RED — write the plan tests** - - In `tests/test_connection_factory.py`, add mock-free tests importing - `build_connection_plan` (and `ConnectionPlan`) from `db_retry.connections`: - - multi-host DSN (`host=host1:5432&host=host2:5432`, with a - `target_session_attrs`): assert `set(plan.failover) == {("host1",5432),("host2",5432)}`; - `list(zip(plan.primary_host, plan.primary_port)) == list(plan.failover)` - (host/port stay paired through the shuffle); `plan.target_session_attrs == - SessionAttribute("read-write")` (or whatever the DSN sets); `"host"`, - `"port"`, `"target_session_attrs"` are NOT in `plan.connect_args`. - - single-host DSN: `plan.failover == ()`; `plan.primary_host == "host"` (scalar); - `plan.primary_port` is the scalar/None; tsa correct. - Run `uv run pytest tests/test_connection_factory.py -k plan` → fails - (ImportError / AttributeError). No DB needed for these. - -- [ ] **Step 2: GREEN — write the plan** - - In `db_retry/connections.py` add the `ConnectionPlan` dataclass - (`kw_only=True, frozen=True, slots=True`, `connect_args: Mapping`, - `target_session_attrs`, `primary_host`, `primary_port`, `failover`) and the - pure `build_connection_plan(url)` per the spec: `create_connect_args`, pop + - wrap tsa, pop host/port, multi-host → `zip(strict=True)` + `random.shuffle` - once → derive primary + failover from the same shuffled pairs; single-host → - scalars + `failover=()`. No timeout, no logging, no `asyncpg.connect`. - - Run `uv run pytest tests/test_connection_factory.py -k plan` → pass. No mock. - -- [ ] **Step 3: Commit** - - ```bash - git add db_retry/connections.py tests/test_connection_factory.py - git commit -m "feat: extract pure build_connection_plan from connection factory" - ``` - ---- - -### Task 2: Rewire the factory onto the plan + single `_connect` - -**Files:** -- Modify: `db_retry/connections.py` - -Replace the factory's inline parse + duplicated connect with the plan + a single -`_connect` helper. Behaviour-preserving. - -- [ ] **Step 1: Edit** - - - Add `async def _connect(plan, host, port, timeout)` — the single - `asyncpg.connect(**plan.connect_args, host=host, port=port, timeout=timeout, - target_session_attrs=plan.target_session_attrs)` call. - - Add `_reshuffled(failover)` — returns a freshly shuffled copy each call - (e.g. `random.sample(failover, len(failover))`), preserving the per-call - failover re-shuffle. - - Rewrite `build_connection_factory`: `plan = build_connection_plan(url)` once; - the closure tries `_connect(plan, plan.primary_host, plan.primary_port, - timeout)`, on `TimeoutError` reraises if `not plan.failover` else logs and - falls through; iterates `_reshuffled(plan.failover)` calling `_connect`, - swallowing `(TimeoutError, OSError, asyncpg.TargetServerAttributeNotMatched)` - with the per-host warning; raises `TargetServerAttributeNotMatched` naming - `plan.target_session_attrs` if all fail. - - Remove the now-inlined parse code and the duplicated connect. Drop imports - that are now only used inside `build_connection_plan` if they end up unused - at module scope (most stay). Keep the warning log strings verbatim. - - Keep the per-call re-shuffle in the loop — do NOT move it into - `build_connection_plan`. - -- [ ] **Step 2: Verify** - - - `just test` (Docker Postgres) → all green, including the four existing loop - tests (success / single-host reraise / multi-host all-fail / fail-then-success). - - `just lint-ci` → clean. - -- [ ] **Step 3: Commit** - - ```bash - git add db_retry/connections.py - git commit -m "refactor: drive connection factory through ConnectionPlan and one _connect" - ``` - ---- - -### Task 3: Promote the architecture doc + finalize bundle - -**Files:** -- Modify: `architecture/connections.md` -- Modify: `planning/changes/2026-06-26.03-connection-plan-split/design.md` (summary) - -- [ ] **Step 1: Edit `architecture/connections.md`** - - Document the split: `build_connection_plan` → `ConnectionPlan` (primary + - failover, host/port paired through the shuffle), the single `_connect` helper, - and that the per-call failover re-shuffle lives in the loop while the bulk - shuffle is computed once at build. Keep the two-stage rationale and the - signature of `build_connection_factory` (unchanged public surface). No - frontmatter. - -- [ ] **Step 2: Finalize summary + verify planning** - - Set `design.md` `summary:` to the realized result. `just check-planning` → - `planning: OK`. - -- [ ] **Step 3: Commit** - - ```bash - git add architecture/connections.md planning/ - git commit -m "docs: promote connection-plan split into architecture/connections.md" - ``` - ---- - -### Task 4: Ship - -- [ ] **Step 1:** `just lint-ci` and `just test` both green. -- [ ] **Step 2:** Push `feat/connection-plan-split`, open PR, watch CI. diff --git a/planning/changes/2026-06-27.01-connect-args-readonly/change.md b/planning/changes/2026-06-27.01-connect-args-readonly.md similarity index 100% rename from planning/changes/2026-06-27.01-connect-args-readonly/change.md rename to planning/changes/2026-06-27.01-connect-args-readonly.md diff --git a/planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md b/planning/changes/2026-06-30.01-python-3.11-3.12-support.md similarity index 100% rename from planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md rename to planning/changes/2026-06-30.01-python-3.11-3.12-support.md diff --git a/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md b/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md deleted file mode 100644 index 49c8922..0000000 --- a/planning/changes/2026-06-30.01-python-3.11-3.12-support/plan.md +++ /dev/null @@ -1,337 +0,0 @@ -# python-3.11-3.12-support — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Lower the supported Python floor from 3.13 to 3.11 — rewrite -`retry.py` off PEP 695, widen packaging metadata, and prove 3.11-3.14 in a CI -matrix. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/python-3.11-3.12-support` (already created and checked out; -the design commit is on it). - -**Commit strategy:** Per-task commits. - -## Global Constraints - -- Python floor: `requires-python = ">=3.11,<4"`. Code must parse and run on - 3.11 through 3.14. -- No new dependency. `ParamSpec`, `TypeVar`, `TypeAlias` are stdlib `typing` - since 3.10 — do not add `typing_extensions`. -- No runtime behavior change: retry/failover/transaction semantics stay - identical. The existing test suite is the behavior contract. -- All imports at module level (project rule). Annotate all function arguments. -- Type-checker is `ty`; suppression comments are `ty: ignore` (never - `type: ignore`). -- Coverage gate `--cov-fail-under=100` must stay green on every version. -- Promote behavior changes into `architecture/.md` in this same PR; - finalize the bundle `summary` at ship. Run `just check-planning` before push. - ---- - -### Task 1: Rewrite `retry.py` off PEP 695 - -**Files:** -- Modify: `db_retry/retry.py:22-38` -- Modify: `architecture/retry.md:17-19` -- Test: `tests/test_retry.py` (existing, unchanged) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `postgres_retry` keeps its exact public contract — two - `typing.overload`s, callable as bare `@postgres_retry` (returns the wrapped - `_Func`) and as `@postgres_retry(retries=N)` (returns a `_Decorator`). The - named module-level aliases `_Func` and `_Decorator` are retained. - -This is a behavior-preserving typing refactor. There is no new local test that -can prove 3.11 syntax-compatibility (the local interpreter is 3.13+); the proof -is the 3.11 CI leg added in Task 3. Locally we verify the rewrite preserves -typing (`ty check`) and behavior (existing `test_retry.py`). - -- [ ] **Step 1: Confirm the existing retry tests pass before touching code** - - Run: `uv run pytest tests/test_retry.py -v` - Expected: PASS (all existing tests green). This is the green baseline the - refactor must preserve. - -- [ ] **Step 2: Replace the PEP 695 declarations** - - In `db_retry/retry.py`, replace the current lines 22-38: - - ```python - type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]] - type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]] - - - @typing.overload - def postgres_retry[**P, T](func: _Func[P, T], *, retries: int | None = ...) -> _Func[P, T]: ... - - - @typing.overload - def postgres_retry[**P, T](func: None = ..., *, retries: int | None = ...) -> _Decorator[P, T]: ... - - - def postgres_retry[**P, T]( - func: _Func[P, T] | None = None, - *, - retries: int | None = None, - ) -> _Func[P, T] | _Decorator[P, T]: - ``` - - with the pre-695 form (module-level `P`/`T`, `TypeAlias` aliases): - - ```python - P = typing.ParamSpec("P") - T = typing.TypeVar("T") - - _Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]] - _Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func] - - - @typing.overload - def postgres_retry(func: _Func, *, retries: int | None = ...) -> _Func: ... - - - @typing.overload - def postgres_retry(func: None = ..., *, retries: int | None = ...) -> _Decorator: ... - - - def postgres_retry( - func: _Func | None = None, - *, - retries: int | None = None, - ) -> _Func | _Decorator: - ``` - - Leave the function body (the `decorator` / `wrapped_method` nesting and the - `tenacity.AsyncRetrying` construction, currently lines 39-55) completely - unchanged. `import typing` is already present at the top — do not re-import. - -- [ ] **Step 3: Type-check the rewrite** - - Run: `uv run ty check` - Expected: PASS, no errors. Confirms the overloads and aliases still - type-check (no inference regression from dropping PEP 695). - -- [ ] **Step 4: Run the retry tests to confirm behavior is preserved** - - Run: `uv run pytest tests/test_retry.py -v` - Expected: PASS — same set of tests green as in Step 1. - -- [ ] **Step 5: Promote into `architecture/retry.md`** - - In `architecture/retry.md`, in the "Public surface" section, append a - sentence to the paragraph ending at line 19 (after - "...it returns a decorator.") noting the typing form: - - ```markdown - The generics are expressed with module-level `typing.ParamSpec`/`TypeVar` - and `TypeAlias` aliases (`_Func`, `_Decorator`) rather than PEP 695 syntax, - so the module parses on Python 3.11+. - ``` - -- [ ] **Step 6: Commit** - - ```bash - git add db_retry/retry.py architecture/retry.md - git commit -m "refactor: express postgres_retry generics pre-PEP-695 for 3.11" - ``` - ---- - -### Task 2: Widen packaging metadata and docs - -**Files:** -- Modify: `pyproject.toml:8` (requires-python), `:11-18` (classifiers), `:58` - (ruff target-version) -- Modify: `README.md:216` - -**Interfaces:** -- Consumes: nothing. -- Produces: a package installable on 3.11+; ruff linting at `py311` target - (which, being below 3.12, will not suggest reverting to PEP 695 syntax). - -- [ ] **Step 1: Lower the Python floor in `pyproject.toml`** - - Change line 8 from: - - ```toml - requires-python = ">=3.13,<4" - ``` - - to: - - ```toml - requires-python = ">=3.11,<4" - ``` - -- [ ] **Step 2: Add the 3.11 and 3.12 classifiers** - - In the `classifiers` list, add the two entries above the existing 3.13 line - so the block reads: - - ```toml - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - ``` - -- [ ] **Step 3: Lower the ruff target version** - - Change line 58 from: - - ```toml - target-version = "py313" - ``` - - to: - - ```toml - target-version = "py311" - ``` - -- [ ] **Step 4: Update the README support line** - - In `README.md`, change line 216 from `- Python 3.13+` to `- Python 3.11+`. - -- [ ] **Step 5: Run the full lint gate** - - Run: `just lint-ci` - Expected: PASS — ruff `--check`, ruff format `--check`, eof-fixer, and - `ty check` all clean at the new `py311` target. In particular, ruff must NOT - raise UP040/UP046/UP047 (PEP 695 upgrade hints) against the Task 1 rewrite; - if it does, the target lowering in Step 3 was not applied. - -- [ ] **Step 6: Commit** - - ```bash - git add pyproject.toml README.md - git commit -m "chore: lower supported Python floor to 3.11" - ``` - ---- - -### Task 3: Add the CI version matrix - -**Files:** -- Modify: `.github/workflows/_checks.yml:18-44` (the `pytest` job) - -**Interfaces:** -- Consumes: the 3.11-compatible package from Tasks 1-2. -- Produces: a `pytest` job that runs once per `3.11/3.12/3.13/3.14`, each - against the postgres service, each holding the 100% coverage gate. - -- [ ] **Step 1: Add the matrix and parameterize the interpreter** - - In `.github/workflows/_checks.yml`, edit the `pytest` job (lines 18-44). Add - a `strategy` block under `runs-on`, and change the hardcoded - `uv python install 3.13` to use the matrix value. The job becomes: - - ```yaml - pytest: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - services: - postgres: - image: postgres:latest - env: - POSTGRES_DB: postgres - POSTGRES_PASSWORD: password - POSTGRES_USER: postgres - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8.2.0 - - run: uv python install ${{ matrix.python-version }} - - run: | - uv sync --all-extras --no-install-project - uv run --no-sync pytest . --cov=. --cov-report xml - env: - PYTHONDONTWRITEBYTECODE: 1 - PYTHONUNBUFFERED: 1 - DB_DSN: postgresql+asyncpg://postgres:password@127.0.0.1/postgres - ``` - - Leave the `lint` job (lines 5-16) unchanged — it stays pinned to 3.13. - -- [ ] **Step 2: Validate the workflow YAML parses** - - Run: `uv run python -c "import yaml,pathlib; yaml.safe_load(pathlib.Path('.github/workflows/_checks.yml').read_text())"` - Expected: no output, exit 0 (valid YAML). - -- [ ] **Step 3: Commit** - - ```bash - git add .github/workflows/_checks.yml - git commit -m "ci: run pytest across Python 3.11-3.14" - ``` - ---- - -### Task 4: Finalize the planning bundle and open the PR - -**Files:** -- Modify: `planning/changes/2026-06-30.01-python-3.11-3.12-support/design.md` - (frontmatter `summary`) - -**Interfaces:** -- Consumes: the shipped Tasks 1-3. -- Produces: a validated planning bundle and a PR with CI running. - -- [ ] **Step 1: Finalize the bundle summary to the realized result** - - The `summary:` was written as intent; confirm it states what shipped. Leave - it as-is if still accurate, or tighten to the realized result (one line). - -- [ ] **Step 2: Validate planning shape** - - Run: `just check-planning` - Expected: `planning: OK`. - -- [ ] **Step 3: Run the full local pipeline once** - - Run: `just lint-ci` then `uv run pytest` - Expected: both PASS locally (DB_DSN set, or via `just test` in Docker). - -- [ ] **Step 4: Push and open the PR** - - ```bash - git push -u origin feat/python-3.11-3.12-support - gh pr create --fill - ``` - - Then watch CI: `gh pr checks --watch`. The four pytest matrix legs - (3.11-3.14) and the lint leg must all go green. The 3.11 leg is the real - proof that the Task 1 rewrite is syntax-compatible. - ---- - -## Self-review notes - -- **Spec coverage:** retry.py rewrite (Task 1), pyproject requires-python + - classifiers + ruff target (Task 2), README (Task 2), CI matrix (Task 3), - architecture promotion (Task 1 Step 5), bundle finalize (Task 4). All design - sections mapped. -- **No new tests:** by design — the existing suite is the behavior contract and - the CI matrix is the cross-version proof. No version-gated branches added. -- **Type consistency:** alias names `_Func` / `_Decorator` and the public name - `postgres_retry` are used identically across Task 1 and the architecture - promotion. diff --git a/planning/index.py b/planning/index.py index f10f5e0..2d70ac3 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,14 +1,12 @@ -# ruff: noqa: INP001, D212 # planning/ is not a Python package; the module docstring uses the D213 (second-line summary) style, so D212 is suppressed -""" -Generate the planning index from frontmatter. +# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) +"""Generate the planning index from frontmatter. -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 -then decisions, newest-first. Never writes a file: +Run via ``just index``. Globs ``planning/changes/*.md`` and +``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown +listing to stdout — changes then decisions, newest-first. Never writes a file: the listing is a query over the files, not a committed artifact. -``date`` and ``slug`` are derived from the directory / file name, not +``date`` and ``slug`` are derived from the file name, not frontmatter — the name is the single source of truth for both. """ @@ -17,12 +15,10 @@ import sys -CHANGES_DIR = pathlib.Path(__file__).parent / "changes" -DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions" +ROOT = pathlib.Path(__file__).parent VALID_DECISION_STATUS = {"accepted", "superseded"} -BUNDLE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") +CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"} SPEC_REQUIRED = ("summary",) DECISION_REQUIRED = ("status", "summary") @@ -47,7 +43,7 @@ def parse_frontmatter(text: str) -> dict[str, str]: def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a dir/file name into ``fields``.""" + """Inject ``date``/``slug`` derived from a file name into ``fields``.""" match = pattern.match(name) if match: fields["date"] = match.group("date") @@ -55,32 +51,29 @@ def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[ return fields -def load_bundles() -> list[dict[str, str]]: - """Read each bundle's summary; derive date/slug from the directory name.""" - bundles: list[dict[str, str]] = [] - if not CHANGES_DIR.is_dir(): - return bundles - for bundle in sorted(CHANGES_DIR.iterdir()): - if not bundle.is_dir(): - continue - spec = bundle / "design.md" - if not spec.exists(): - spec = bundle / "change.md" - if not spec.exists(): +def load_changes(root: pathlib.Path) -> list[dict[str, str]]: + """Read each change file's summary; derive date/slug from the file name.""" + changes_dir = root / "changes" + changes: list[dict[str, str]] = [] + if not changes_dir.is_dir(): + return changes + for path in sorted(changes_dir.glob("*.md")): + if path.name == "README.md" or path.name.startswith(("_", ".")): continue - fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE) - fields["path"] = f"changes/{bundle.name}/{spec.name}" - fields["name"] = bundle.name - bundles.append(fields) - return bundles + fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) + fields["path"] = f"changes/{path.name}" + fields["name"] = path.stem + changes.append(fields) + return changes -def load_decisions() -> list[dict[str, str]]: +def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: """Read each decision's frontmatter; derive date/slug from the file name.""" + decisions_dir = root / "decisions" decisions: list[dict[str, str]] = [] - if not DECISIONS_DIR.is_dir(): + if not decisions_dir.is_dir(): return decisions - for path in sorted(DECISIONS_DIR.glob("*.md")): + for path in sorted(decisions_dir.glob("*.md")): if path.name == "README.md" or path.name.startswith("_"): continue fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) @@ -90,24 +83,24 @@ def load_decisions() -> list[dict[str, str]]: return decisions -def format_row(bundle: dict[str, str]) -> str: - """Render one bundle as a Markdown list item.""" - slug = bundle.get("slug", "?") - path = bundle.get("path", "") - date = bundle.get("date", "") - summary = bundle.get("summary") or "(no summary)" +def format_row(row: dict[str, str]) -> str: + """Render one change or decision as a Markdown list item.""" + slug = row.get("slug", "?") + path = row.get("path", "") + date = row.get("date", "") + summary = row.get("summary") or "(no summary)" line = f"- **[{slug}]({path})** ({date}) — {summary}" - if bundle.get("supersedes"): - line += f" _(supersedes {bundle['supersedes']})_" - if bundle.get("superseded_by"): - line += f" _(superseded by {bundle['superseded_by']})_" + if row.get("supersedes"): + line += f" _(supersedes {row['supersedes']})_" + if row.get("superseded_by"): + line += f" _(superseded by {row['superseded_by']})_" return line -def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: +def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: """Render the full Markdown listing: changes then decisions, newest-first.""" out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True) + change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] out += ["", "## Decisions", ""] decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) @@ -121,32 +114,15 @@ def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) -def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None: - """Validate a design.md / change.md spec file (requires `summary`).""" +def _check_change(path: pathlib.Path, violations: list[str]) -> None: + """Validate one change file (requires `summary`).""" + rel = f"changes/{path.name}" + if CHANGE_RE.match(path.stem) is None: + violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") fields = parse_frontmatter(path.read_text(encoding="utf-8")) _require(fields, SPEC_REQUIRED, rel, violations) -def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None: - """Validate one change bundle directory.""" - rel = f"changes/{bundle.name}" - if BUNDLE_RE.match(bundle.name) is None: - violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'") - violations.extend( - f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})" - for child in sorted(bundle.iterdir()) - if child.name not in ALLOWED_BUNDLE_FILES - ) - design = bundle / "design.md" - change = bundle / "change.md" - if not design.exists() and not change.exists(): - violations.append(f"{rel}: bundle has neither design.md nor change.md") - for spec_file in (design, change): - if spec_file.exists(): - _check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations) - # plan.md carries no frontmatter — its identity comes from the bundle dir. - - def _check_decision(path: pathlib.Path, violations: list[str]) -> None: """Validate one decision file (requires `status` + `summary`).""" rel = f"decisions/{path.name}" @@ -159,25 +135,39 @@ def _check_decision(path: pathlib.Path, violations: list[str]) -> None: violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") -def check() -> list[str]: - """Validate every bundle and decision; return the list of violation strings.""" +def check(root: pathlib.Path) -> list[str]: + """Validate every change file and decision; return the list of violation strings.""" violations: list[str] = [] - if CHANGES_DIR.is_dir(): - for bundle in sorted(CHANGES_DIR.iterdir()): - if bundle.is_dir(): - _check_bundle(bundle, violations) - if DECISIONS_DIR.is_dir(): - for path in sorted(DECISIONS_DIR.glob("*.md")): + changes_dir = root / "changes" + decisions_dir = root / "decisions" + if changes_dir.is_dir(): + for path in sorted(changes_dir.iterdir()): + if path.is_dir(): + violations.append( + f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " + f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" + ) + continue + if path.name == "README.md" or path.name.startswith(("_", ".")): + continue + if path.suffix != ".md": + violations.append(f"changes/{path.name}: unexpected non-md file in changes/") + else: + _check_change(path, violations) + if decisions_dir.is_dir(): + for path in sorted(decisions_dir.glob("*.md")): if path.name == "README.md" or path.name.startswith("_"): continue _check_decision(path, violations) return violations -def main() -> int: - """Print the listing to stdout, or validate bundles with --check.""" - if "--check" in sys.argv[1:]: - violations = check() +def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: + """Print the listing to stdout, or validate change files and decisions with --check.""" + argv = sys.argv[1:] if argv is None else argv + root = ROOT if root is None else root + if "--check" in argv: + violations = check(root) if violations: sys.stderr.write(f"planning: {len(violations)} violation(s)\n") for violation in violations: @@ -185,7 +175,7 @@ def main() -> int: return 1 sys.stdout.write("planning: OK\n") return 0 - sys.stdout.write(render(load_bundles(), load_decisions())) + sys.stdout.write(render(load_changes(root), load_decisions(root))) return 0