diff --git a/.bestpractices.json b/.bestpractices.json
index dce3b9aa..ebabe673 100644
--- a/.bestpractices.json
+++ b/.bestpractices.json
@@ -99,7 +99,7 @@
"build_floss_tools_justification": "pip, uv, hatchling, pytest, ruff and pyright are all FLOSS, and the build runs on ubuntu-latest and windows-latest GitHub runners.",
"test_status": "Met",
- "test_justification": "An automated test suite of 6348 tests lives under tests_py/ (measured 2026-07-28 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py",
+ "test_justification": "An automated test suite of 6373 tests lives under tests_py/ (measured 2026-07-28 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py",
"test_invocation_status": "Met",
"test_invocation_justification": "The whole suite runs with a single 'pytest' command, documented in CONTRIBUTING.md under Testing along with per-layer subsets: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md",
@@ -114,7 +114,7 @@
"test_policy_justification": "CONTRIBUTING.md carries an explicit, mandatory testing policy: new functionality ships with tests in the automated suite, a bug fix carries a regression test that fails on the pre-fix code, and each failure path asserts its observable effect including the signal it emits \u2014 https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory. The per-tool checklist ('Add a unit test', 'Add an integration test if the tool touches the database') and the five-element mechanism checklist restate it per change type.",
"tests_are_added_status": "Met",
- "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6348 tests alongside the v4.x feature series, and CI runs it on every pull request.",
+ "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6373 tests alongside the v4.x feature series, and CI runs it on every pull request.",
"tests_documented_added_status": "Met",
"tests_documented_added_justification": "The requirement is written into the documented instructions for change proposals: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory states that every change adding or altering observable behaviour must arrive with tests in the same PR, the per-tool and per-mechanism checklists repeat it as a concrete step, and .github/PULL_REQUEST_TEMPLATE.md requires a Test plan section in every pull request.",
@@ -189,7 +189,7 @@
"static_analysis_often_justification": "CodeQL default setup analyses each push and pull request and additionally runs on a weekly schedule, so analysis happens per change rather than per release.",
"dynamic_analysis_status": "Unmet",
- "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6348-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.",
+ "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6373-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.",
"dynamic_analysis_unsafe_status": "N/A",
"dynamic_analysis_unsafe_justification": "Cortex is written in Python, a memory-safe language, so the memory-safety tooling this criterion asks about (ASan, Valgrind) does not apply.",
@@ -297,7 +297,7 @@
"interfaces_current_justification": "The stack targets currently supported runtimes and APIs: Python 3.10 through 3.13, all four in the CI matrix, with pgvector 0.3+/PostgreSQL 17 and current major versions of FastMCP, Pydantic v2, numpy and sentence-transformers. Deprecated interfaces are removed rather than wrapped \u2014 the standing rule is one-shot migrations with no back-compat shims (CLAUDE.md), and CI runs on the newest released Python so a deprecation surfaces as a warning in the build rather than as a surprise at end of life.",
"automated_integration_testing_status": "Met",
- "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6348 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.",
+ "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6373 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.",
"regression_tests_added50_status": "Met",
"regression_tests_added50_justification": "Measured on 2026-07-27 over the merged pull requests titled as fixes in the preceding six months (2026-01-27 onward): 23 of 30 \u2014 76.7% \u2014 changed the test tree in the same PR, against the 50% this criterion asks for. (The measure counts a PR as carrying tests when it touches tests_py/ or tests_js/, which is a proxy for 'added a regression test'; the policy behind it is written down at https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory \u2014 a bug fix carries a regression test that fails on the pre-fix code.)",
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 902b4f31..7bde5aa8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -173,6 +173,10 @@ jobs:
fi
echo "collected ${collected} tests"
python scripts/check_doc_claims.py --test-count "${collected}"
+ # The tests badge is a committed SVG carrying this same number, so
+ # it is checked where the number is known. The other repo-derived
+ # badges are checked in the static job, which needs no suite.
+ python scripts/generate_repo_badges.py --check --test-count "${collected}"
- name: Upload coverage
if: matrix.python-version == '3.12'
@@ -395,6 +399,15 @@ jobs:
- name: Check documentation claims
run: python scripts/check_doc_claims.py
+ # The README's repo-derived badges are committed SVGs, not hotlinked
+ # images, so nothing regenerates them on view: a figure that moves
+ # leaves the badge asserting the old one. Checked here, on every push
+ # and PR, because that drift is introduced at commit time. Static only
+ # (no network, no suite) — the tests badge needs a collected count and
+ # is checked in the test job instead.
+ - name: Check committed badges
+ run: python scripts/generate_repo_badges.py --check
+
typecheck:
name: Type Check
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c74cb08..34637085 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
-- **MCP Toplist rank badge, self-hosted and refreshed on a cron** — `assets/badge-mcp-toplist.svg`, `scripts/refresh_mcp_toplist_badge.py`, `.github/workflows/mcp-toplist-badge.yml`. The badge records that Cortex ranks in the top 1.2% of MCP servers tracked by MCP Toplist (#964 of 81,919, read 2026-07-28). It is a **committed file, not a hotlinked remote image**: a remote badge URL is a third-party beacon fired on every README view, and it lets its host restate the claim at any time with no commit in this repository. The arriving PR (#241, closed) proposed exactly that. The cost of the static choice is that the badge cannot self-update — the date it carries is part of the claim and goes stale by INACTION, and inaction never opens a PR — so a monthly workflow regenerates it and proposes the diff. Monthly is deliberate: the badge stamps a month, so it is the least frequent cadence that keeps the date honest and the most frequent one that does not churn a PR proposing an identical file. Wording is **attributive throughout** (`RANKED in this tier by MCP Toplist`, never `is a top-1.2% server`), because upstream's own methodology page states the score "is a popularity and activity signal, not a quality assessment" and keeps ~25% of its weighting (organic reach, package downloads) undisclosed — the figure is attributable, not reproducible. Every generated SVG carries its own audit trail in a comment: source path, raw rank and field size, the percentile arithmetic, and the verify URL, so the next maintainer can re-derive the claim from the file alone. **Two extraction paths, fail-closed:** the structured export at `/data/leaderboard.json` is tried first but returns HTTP 503 (measured 2026-07-28: 3/3 attempts, 8–14s each under a browser UA, i.e. a server-side generation timeout, not UA gating or rate limiting), so its schema has never been observed and the parser accepts only a narrow set of documented candidate shapes under strict validation rather than guessing at one; the fallback is the server page's prose sentence `ranks #N of M servers tracked`, the ONLY construct on that page carrying both numbers (the `
`, og/twitter meta tags and all four JSON-LD blocks carry the rank without the total, so none can yield a percentile). Both paths feed one validator that refuses a non-numeric, zero, negative, or beyond-the-field figure — guarding the division in `percentile()` and the semantics of the claim — and a figure that fails validation is **never written**: the script exits non-zero, the badge keeps its last good value, and the run goes red. A fallback is never silent; the path that failed is reported even when a later one succeeds. No `pull_request` trigger, so a required check never depends on a third party's uptime; the 51 unit tests cover the logic with no network at all. Caught by those tests before shipping: a top-of-field rank renders the tier as `Top <0.1%`, whose unescaped `<` **made the badge invalid XML** — text and attributes are now XML-escaped, verified across the rank-1, mid-field and last-place boundaries. Note that PRs opened with the default `GITHUB_TOKEN` do not trigger workflows, so `main`'s required checks will not start on them; the workflow prefers an optional `BADGE_REFRESH_TOKEN` secret when present. Suite grows 6297 → 6348.
+- **The README's repo-derived badges are now self-hosted SVGs, gated in CI** — `assets/badge-{license,python,tests,references,version}.svg`, `scripts/generate_repo_badges.py`, `scripts/badge_render.py`. Five hotlinked `img.shields.io` images became committed files, for the reason the MCP Toplist badge already was one: a remote badge URL is a third-party request fired on every README view, and it lets its host restate our claim with no commit in this repository. **Only repo-derived figures were converted**, and the line is deliberate — every one of these (licence, Python floor, collected test count, bibliography size, package version) is determined BY THIS REPOSITORY, so a committed copy can always be made true again from the working tree with no network access. That is why they are kept honest by a **blocking `--check` gate on every push and PR** rather than by a cron: drift is caught where it is introduced. **Two badges were deliberately NOT converted and must not be:** the CI status badge reports the LIVE result of the last run on main, so a committed copy would assert "passing" while main was broken — a static build-status badge is a false claim by construction, not merely a stale one — and it is GitHub-hosted, so it is not a third-party beacon in the first place; the OpenSSF Best Practices badge reflects an external body's live assessment that can be downgraded without any commit here, and `.bestpractices.json` separately justifies displaying THEIR badge image. The MCP Toplist badge sits between the two and stays committed because it carries an explicit "as of " stamp, which keeps a stale copy a true statement about a point in time. **The conversion had to rewire the gate it would otherwise have silenced:** `check_doc_claims.py` enforced the version and test-count claims by regex over the shields.io URLs (`badge/version-(\d+\.\d+\.\d+)`, `badge/tests-(\d+)_passing`), so self-hosting alone would have left both patterns matching nothing while the gate still reported success. They now read the figure out of the committed SVG's own `` and **fail closed** on a missing file or an unmatched title — the predecessor's `if badge and ...` passed silently the moment its subject disappeared. A new check also fails any reintroduced `img.shields.io` hotlink in the README, so reverting this is loud rather than quiet. Found by the new tests before shipping: `--` is illegal inside an XML comment, and the first cut described its own gate as `--check` and its source as `pytest --collect-only` in the provenance comment, leaving **all five badges unparseable**; the renderer now parses every badge it produces and refuses to return one that is not well-formed, so no future wording can reintroduce the class. The shared renderer was extracted from the MCP Toplist refresher first, as a separate behavior-preserving commit whose proof is that `assets/badge-mcp-toplist.svg` is byte-identical and its 51 tests pass unchanged. Suite grows 6348 → 6373.
+- **MCP Toplist rank badge, self-hosted and refreshed on a cron** — `assets/badge-mcp-toplist.svg`, `scripts/refresh_mcp_toplist_badge.py`, `.github/workflows/mcp-toplist-badge.yml`. The badge records that Cortex ranks in the top 1.2% of MCP servers tracked by MCP Toplist (#964 of 81,919, read 2026-07-28). It is a **committed file, not a hotlinked remote image**: a remote badge URL is a third-party beacon fired on every README view, and it lets its host restate the claim at any time with no commit in this repository. The arriving PR (#241, closed) proposed exactly that. The cost of the static choice is that the badge cannot self-update — the date it carries is part of the claim and goes stale by INACTION, and inaction never opens a PR — so a monthly workflow regenerates it and proposes the diff. Monthly is deliberate: the badge stamps a month, so it is the least frequent cadence that keeps the date honest and the most frequent one that does not churn a PR proposing an identical file. Wording is **attributive throughout** (`RANKED in this tier by MCP Toplist`, never `is a top-1.2% server`), because upstream's own methodology page states the score "is a popularity and activity signal, not a quality assessment" and keeps ~25% of its weighting (organic reach, package downloads) undisclosed — the figure is attributable, not reproducible. Every generated SVG carries its own audit trail in a comment: source path, raw rank and field size, the percentile arithmetic, and the verify URL, so the next maintainer can re-derive the claim from the file alone. **Two extraction paths, fail-closed:** the structured export at `/data/leaderboard.json` is tried first but returns HTTP 503 (measured 2026-07-28: 3/3 attempts, 8–14s each under a browser UA, i.e. a server-side generation timeout, not UA gating or rate limiting), so its schema has never been observed and the parser accepts only a narrow set of documented candidate shapes under strict validation rather than guessing at one; the fallback is the server page's prose sentence `ranks #N of M servers tracked`, the ONLY construct on that page carrying both numbers (the ``, og/twitter meta tags and all four JSON-LD blocks carry the rank without the total, so none can yield a percentile). Both paths feed one validator that refuses a non-numeric, zero, negative, or beyond-the-field figure — guarding the division in `percentile()` and the semantics of the claim — and a figure that fails validation is **never written**: the script exits non-zero, the badge keeps its last good value, and the run goes red. A fallback is never silent; the path that failed is reported even when a later one succeeds. No `pull_request` trigger, so a required check never depends on a third party's uptime; the 51 unit tests cover the logic with no network at all. Caught by those tests before shipping: a top-of-field rank renders the tier as `Top <0.1%`, whose unescaped `<` **made the badge invalid XML** — text and attributes are now XML-escaped, verified across the rank-1, mid-field and last-place boundaries. Note that PRs opened with the default `GITHUB_TOKEN` do not trigger workflows, so `main`'s required checks will not start on them; the workflow prefers an optional `BADGE_REFRESH_TOKEN` secret when present. Suite grows 6297 → 6373.
- **Pyright is now a zero-diagnostic blocking gate** (#197, final family of the maximal-strictness program). The 568-diagnostic per-rule ratchet backlog was burned to **zero** at `typeCheckingMode: "standard"` (pyright 1.1.410, measured 2026-07-28): no rule disabled, no floor raised; the single per-site suppression is the unpublished optional `cortex_beam_abstain` import whose `except ImportError` arm is the documented degraded mode. The ratchet machinery (`typecheck-baseline.json` + `scripts/check_pyright_ratchet.py`) is retired — CI fails on ANY diagnostic via pyright's own exit code, and the CI type-check env installs the `[otel]` extra so the exporter imports resolve. The burn-down was fixes, not annotations-to-match: a typed host contract for the eight `PgMemoryStore` mixins (`pg_store_host.PgStoreHost` + `MaterializedCursor`, whose honest `DictRow` typing surfaced ten unchecked `INSERT..RETURNING` sites, now `one()` with a real error), a cross-backend `StoreConnection` union for the 16 shared query modules (the psycopg-only annotation had switched checking off for every SQLite call path), and **SQLite store parity for eight methods callers already used unconditionally** — `acquire_interactive`/`acquire_batch`, `_execute`, `search_newer_neighbors`, `update_forgetting_pressure_accum`, `get_memories_by_tag`, `iter_memories_for_decay`, `find_co_accessed_pairs` — each of which previously raised `AttributeError` on the SQLite backend and was swallowed into silent degradation by broad stage boundaries. Latent bugs fixed en route, each with a regression test: the compat cursor lacked `executemany` (SQLite wiki page-sources writes crashed), `lastrowid` honesty (insert paths now raise on a broken row-id contract instead of masking it with a stale `type: ignore`), the pipeline installer accepted a success result carrying no cargo path (None flowed into the build argv), `update_style_ema(None, None)` returned `None` against a `dict` signature, `encode_session` died with a bare `TypeError` on a direction-less feature (now refuses loudly, naming it), `get_causal_chain` could return `reason=None`, and active forgetting sent a `None` timestamp into the store. `.bestpractices.json` flips `warnings_strict` to **Met**, citing the ruff select list, the pyright mode, and the measured zero. Suite grows 6275 → 6297.
- **ruff `PLC0415` (import-outside-top-level) and `S608` (string-built SQL) are now blocking lint gates** (#197, fourth rule family of the maximal-strictness program). All 520 production `PLC0415` findings (407 `mcp_server/`, 57 `benchmarks/`, 56 `scripts/`) were triaged one by one: **360 lazy imports moved to module top** — so the import graph is static and a broken module fails at boot, not mid-operation — and the 160 that remain each carry a per-site `# noqa: PLC0415 — ` naming one of six sanctioned justifications: an optional dependency behind an extra, an internal module whose top-level closure hard-imports one (hoisting would break `[sqlite]`-only installs at import time), an ImportError-probe boundary where the except arm IS the degraded mode, an import cycle (partner named; the pre-existing #233 family), the hook latency boundary (per-event hook processes boot in ~0.05 s vs ~0.6 s for the registry closure, measured 2026-07-28 — hoisting the handler/store stack into a hook would multiply every hook event's cost), or a deferral the module itself documents. The hoist is behavior-preserving: the per-module import sweep matches the pre-change baseline exactly (515 modules, the same 6 pre-existing cycle failures), and warm import timings are unchanged. All 44 production `S608` sites carry a per-site `# noqa: S608 — ` naming the exact reason the interpolation is safe (two-literal ternaries, generated placeholder lists, module-level `WHERE` literals, or allowlist-gated identifiers per `docs/ASSURANCE-CASE.md` §5), so any NEW string-built SQL fails CI until it states its mechanism. `tests_py/**` adds both rules to its written per-file ignore (function-level imports in tests are the fixture mechanism; SQL built in tests is fixture setup against a throwaway database).
- **ruff `PLR2004` (magic-value comparison) and `E501` (line-too-long) are now blocking lint gates** (#197, third rule family of the maximal-strictness program). All 420 production `PLR2004` findings (339 `mcp_server/`, 57 `benchmarks/`, 14 `scripts/`, 10 `video/`) were fixed with **zero `# noqa: PLR2004`**: every compared literal became a named constant carrying a `# source:` comment — a real citation where the module documents one (Frey & Morris 1997 / Kandel 2001 / Tse 2007 cascade thresholds, RFC 9110 status bands, FIPS 180-4 digest lengths, issue-quoted gates), a structural rationale for arities (split-parts, tuple lengths), and an explicit `pre-existing tuned value, extracted unchanged; provenance not recorded at introduction` where none is discoverable — never an invented source. `tests_py/**` carries a written per-file ignore (the compared literal in an assertion IS the expected value under test — the spec itself). All 470 `E501` findings (263 `mcp_server/`, 121 `tests_py/`, 48 `benchmarks/`, 37 `scripts/`, 1 `_pipeline`) were fixed by **rewrapping at the unchanged 88-column formatter limit** — string content kept byte-identical via implicit concatenation at existing whitespace (SQL and regex literals machine-verified byte-for-byte) — with exactly two per-site `# noqa: E501 — ` for unsplittable absolute-path tokens; E501 has **no** tests ignore. Two drift risks were closed at the source: `handlers/consolidation/transfer.py` re-declared as bare literals the canonical constants of `core/two_stage_transfer.py` (whose own comment forbids redefinition) and now imports them; `benchmarks/beam/ablation.py`'s copies are named per-module without value drift.
diff --git a/CLAUDE.md b/CLAUDE.md
index daf9be1b..ed5cb271 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ coding write gates, causal graphs, and intent-aware retrieval.
- Install (dev): `uv pip install -e ".[dev]"` — SQLite backend: `".[dev,sqlite]"`
- Environment preflight: `python -m mcp_server.doctor` (backend-aware check list, fix message per check)
-- Tests: `pytest` (full suite, 6348 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing`
+- Tests: `pytest` (full suite, 6373 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing`
- Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough.
- Release gate benchmarks (isolated, ephemeral container — the only source
of truth for pre-tag/floor decisions): `benchmarks/reproduce.sh`. Do NOT
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ec9e3e0d..2e521e73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -46,7 +46,7 @@ bash scripts/setup.sh # macOS / Linux
# Verify everything is wired
uvx --python 3.13 --from "hypermnesia-mcp[postgresql]" cortex-doctor
-# Run tests (6348 tests under tests_py/)
+# Run tests (6373 tests under tests_py/)
pytest
# Run a benchmark
@@ -134,7 +134,7 @@ The full standard lives in
## Testing
```bash
-pytest # full suite (6348 tests)
+pytest # full suite (6373 tests)
pytest tests_py/core # core (pure business logic) only
pytest tests_py/integration # PostgreSQL-backed integration
pytest tests_py/benchmarks -k locomo # subset
diff --git a/README.md b/README.md
index 75e02af2..a81fe014 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -526,7 +526,7 @@ Cortex is **local-first**: your memories, conversations, and profiles stay on yo
## Development
```bash
-pytest # 6348 tests
+pytest # 6373 tests
ruff check . # Lint
ruff format --check . # Format
python scripts/check_doc_claims.py # advertised counts must match the repo
diff --git a/assets/badge-license.svg b/assets/badge-license.svg
new file mode 100644
index 00000000..2e3f7c37
--- /dev/null
+++ b/assets/badge-license.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/badge-python.svg b/assets/badge-python.svg
new file mode 100644
index 00000000..6da8f2e8
--- /dev/null
+++ b/assets/badge-python.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/badge-references.svg b/assets/badge-references.svg
new file mode 100644
index 00000000..35f80e14
--- /dev/null
+++ b/assets/badge-references.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/badge-tests.svg b/assets/badge-tests.svg
new file mode 100644
index 00000000..df2f7d2b
--- /dev/null
+++ b/assets/badge-tests.svg
@@ -0,0 +1,21 @@
+
diff --git a/assets/badge-version.svg b/assets/badge-version.svg
new file mode 100644
index 00000000..1e2e8b3f
--- /dev/null
+++ b/assets/badge-version.svg
@@ -0,0 +1,21 @@
+
diff --git a/docs/ASSURANCE-CASE.md b/docs/ASSURANCE-CASE.md
index 9c1ce20b..646c1f04 100644
--- a/docs/ASSURANCE-CASE.md
+++ b/docs/ASSURANCE-CASE.md
@@ -128,7 +128,7 @@ decoratively:
Standing analysis: CodeQL default setup (Python, JavaScript/TypeScript,
Actions) on every push and pull request plus weekly, currently 0 open alerts;
-OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and 6348 tests run on
+OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and 6373 tests run on
four Python versions, two backends and Windows.
## 6. What this assurance case does NOT claim
diff --git a/scripts/badge_render.py b/scripts/badge_render.py
new file mode 100644
index 00000000..6b1ea815
--- /dev/null
+++ b/scripts/badge_render.py
@@ -0,0 +1,187 @@
+"""Shields-style two-panel badge SVG rendering, shared by the badge generators.
+
+Extracted from refresh_mcp_toplist_badge.py when a second generator
+(generate_repo_badges.py) needed the same geometry. Both emit the same
+anatomy — a dark label panel, a coloured message panel, a drop-shadowed
+text run in each — and differ only in their data and provenance.
+
+Two constraints shape every choice here, and neither is negotiable on the
+one surface these badges exist for (a README rendered by GitHub):
+
+ * No