{"id":"61cfad14-0f0a-47fd-b744-b6f5d48cd7a4","slug":"evenkeel","kind":"mcp","name":"evenkeel","summary":"A FastAPI backend template that keeps its claims checkable — layer contracts, adapter conformance and the OpenAPI document are CI jobs, not README promises. The example is a wallet ledger: money, conc","owner":"archievega","repoUrl":"https://github.com/archievega/evenkeel","license":"MIT","licenseTier":"permissive","trust":"community","stars":3,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-16T03:01:16.000Z","addedAt":"2026-10-21T09:30:59.541Z","logoUrl":"https://avatars.githubusercontent.com/u/59603766?u=0dea86adab687da0f72f3ebb17248dcbeabce8e4&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["cloud-infra","databases","developer-tools"],"tags":["docker","fastapi","observability","python","clean-architecture","dishka","pytest","sqlalchemy"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T23:01:30.616Z","sample":false,"url":"https://conduits.dev/tools/evenkeel","language":"Python","transports":["stdio"],"install":{"pypi":"evenkeel"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# evenkeel\n\n[![CI](https://github.com/archievega/evenkeel/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/archievega/evenkeel/actions/workflows/ci.yml)\n[![layers enforced](https://img.shields.io/badge/layers-enforced-2ea44f)](.importlinter)\n[![mypy strict](https://img.shields.io/badge/mypy-strict-2ea44f)](pyproject.toml)\n[![coverage gate 80%](https://img.shields.io/badge/coverage%20gate-80%25-2ea44f)](.github/workflows/ci.yml)\n[![Python 3.13](https://img.shields.io/badge/python-3.13-3776ab)](pyproject.toml)\n[![MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n\n**A FastAPI backend template that keeps its claims checkable.**\n\nEach badge links to the file that makes it true, and the first one is the job\nthat keeps the rest honest — the layer contract, the strict type check and the\ncoverage floor all run in that pipeline.\n\nClean architecture, ports and adapters, and an opt-in slice system — where every\narchitectural promise is enforced by a machine rather than described in this file.\nThe layer rules are a CI job. The null adapters are held to the real adapters'\ncontract by a shared test suite. Readiness is a query, not a constant.\n\nIt runs with one command and no credentials. Every optional dependency it has —\nRedis for the lock, limiter, idempotency store and concurrency cap; Prometheus;\nthe outbound risk provider; the identity provider — ships a **null adapter**, so\nthe core is a working API on its own and scaling up is a swap in one provider\nmethod rather than a rewrite.\n\n```bash\ndocker compose up\n```\n\nOr without installing anything: **Code → Codespaces → Create codespace**. The\ndevcontainer brings up Postgres, installs the environment and applies the\nmigrations, so the first thing that happens is a working stack rather than a\ndependency error.\n\n![A terminal session against the running API: opening a wallet, a deposit, an\noverdraft refused as an RFC 9457 problem document, a retry that replays instead\nof moving money twice, and another owner's wallet answering 404](docs/demo.gif)\n\nNothing in that recording is staged. It is\n[one VHS tape](tools/demo/api.tape) driven against the same image CI boots in\nits smoke job, and re-recording it is two commands — which is the point: a\nREADME that can be re-run cannot quietly drift from the code.\n\n---\n\n## Why another template\n\nMost FastAPI templates show you how to wire a framework. This one encodes the\nthings that only show up after a service has been in production for a while:\n\n| Concern | What the template does |\n| --- | --- |\n| Readiness | `/ready` actually checks its dependencies and returns 503 when they are down |\n| Money | `Decimal` everywhere, arithmetic across currencies raises |\n| Concurrency | distributed lock **and** optimistic version — each covers what the other cannot |\n| Retries | `Idempotency-Key` is a first-class port, not a per-endpoint hack |\n| Timezones | one `TypeDecorator` at the column boundary, so naive datetimes cannot enter |\n| Errors | RFC 9457 Problem Details from a single handler, status code declared on the error class |\n| Metrics | route templates as labels, never raw paths — the standard way to melt Prometheus |\n| Secrets | redaction lives in the log pipeline, not at call sites — with the records it does not cover written down rather than glossed over |\n| Outbound calls | timeout, budget, concurrency cap, jittered retry, size cap — with the cost of each one measured under load |\n| Architecture | layering is enforced by `import-linter` in CI, not by a README promise |\n\n## Architecture\n\n```mermaid\nflowchart TD\n    HTTP[presentation/http] --> APP[application]\n    MCP[presentation/mcp] --> APP\n    CLI[entrypoints] --> APP\n    APP --> DOM[domain]\n    INFRA[infrastructure/adapters] -.implements.-> PORTS[application/ports]\n    APP --> PORTS\n    SETUP[setup/ioc] -.wires.-> INFRA\n    SETUP -.wires.-> APP\n```\n\nDependencies point one way: `presentation → application → domain`. Infrastructure\ndepends on the ports it implements and nothing depends on infrastructure except the\ncomposition root. The domain imports nothing but the standard library.\n\nA request walks the layers like this:\n\n1. **Router** parses the request and calls one interactor. No business rules.\n2. **Interactor** owns policy — rate limit, transaction boundary, metrics — and delegates.\n3. **Domain** enforces invariants. A wallet cannot go negative because `Wallet.withdraw` says so, not because an endpoint remembered to check.\n4. **Adapters** translate rows to entities and back.\n\n## The example domain\n\nA wallet ledger: open a wallet, deposit, withdraw, list entries. It is deliberately\nnot a to-do list — money forces the template to demonstrate invariants, concurrency,\nidempotency and auditability instead of describing them.\n\n```\nPOST   /v1/wallets                        open a wallet\nGET    /v1/wallets                        list (cursor-paginated)\nGET    /v1/wallets/{id}                   read one\nPOST   /v1/wallets/{id}/deposits          credit    (accepts Idempotency-Key)\nPOST   /v1/wallets/{id}/withdrawals       debit     (accepts Idempotency-Key)\nGET    /v1/wallets/{id}/entries           ledger history\nGET    /health  /ready  /version          operations\nGET    /metrics                           Prometheus, off unless enabled\n```\n\nEvery failure is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)\n`application/problem+json` document, and every status the API can return is\ndeclared in the schema — including the 403, the 409s, the 429 and the 503 that\nmost generated specs omit. Branch on `code`, never on `title`.\n\n## Calling other services\n\nThe one outbound dependency — risk assessment before a balance changes — is the\nreference for any that follow. It ships with a null adapter (`allow-all`), so\nnone of it is in the way until you point `APP__RISK__PROVIDER=http` at a URL.\n\nFailures are values, not exceptions: a timeout, a refused connection, a 500, a\nmalformed body and a full bulkhead all arrive as\n`RiskOutcome.UNAVAILABLE`, and the use case decides what that means. Refused and\nunavailable are never merged — one is the provider working, the other is the\nprovider missing.\n\nThe guards, and what each one covers:\n\n| | |\n| --- | --- |\n| bulkhead, refusing rather than queueing | in-flight calls are bounded, and a caller who cannot be served is refused in under a millisecond instead of paying the provider's latency first |\n| per-attempt timeout **and** an overall budget | the worst case is a number someone chose |\n| retries with full jitter, honouring `Retry-After` | only where a retry can succeed; never a 400, never a malformed 200 |\n| response size cap | a broken upstream cannot spend this process's memory |\n\n\n`docker compose --profile load up` starts a stub provider with dials for\nlatency, failure rate and refusal rate, and `tools/load/wallets.js` drives it\nunder k6. The measurements — including the one that contradicted a docstring in\nthis repository — are in\n[tools/load/README.md](tools/load/README.md), and the reasoning is\n[ADR 6](docs/adr/0006-outbound-calls.md).\n\n## Seeing which guard fired\n\nEvery guard above answers the client with the same `503`. The dashboard is where\nthey stop looking alike.\n\n![The evenkeel dashboard while its one dependency is deliberately broken](docs/img/dashboard.png)\n\n**Read the seconds on that picture as a property of the stub, not of this\nservice.** The run behind it points the API at a provider configured to stall\nfor 700ms with a concurrency cap of 32, then offers it 200 writes a second —\nabout ten times what that path can complete. Nine calls in ten are shed\nimmediately and the rest sit against the retry budget, which is what the tail\nin the latency panel is. The same code with no provider in the way serves the\nsame load in tens of milliseconds. Both runs, and the caveats that make either\nnumber nearly meaningless on a laptop, are in\n[tools/load/README.md](tools/load/README.md).\n\nThe picture exists because a degraded dependency is the only condition under\nwhich the middle row has anything to say.\n\n```bash\nmake observe   # api, postgres, the stalling stub, prometheus, grafana (:3000)\nmake load      # 200 writes/s for 30s against the stalling provider\nmake down      # stop all of it, overlay and volumes included\n```\n\n`make dashboard-image` regenerates it, with the `render` profile up:\n\n```bash\ndocker compose -f compose.yml -f compose.observability.yml --profile load --profile render up -d --wait\nmake dashboard-image\n```\n\nEight alert rules sit alongside it in\n[ops/prometheus/alerts.yml](ops/prometheus/alerts.yml), under one constraint:\n**none of them may fire on an idle service.** Every ratio carries a minimum\nvolume gate, because `0/0` is not the problem — one request, one failure and a\nratio of `1.0` is. An alert that pages on a healthy\ninstance is one everyone learns to ignore, and it takes the rest of the file\nwith it.\n\nThe dashboard is checked against the code by\n`tests/unit/test_observability_stack.py`: every panel and every alert must name\na metric the adapter actually emits, and no scrape label may shadow one the\napplication sets. That last test exists because `service: evenkeel` on the\ntarget looked like harmless decoration and quietly replaced the label naming the\n*dependency* — so `by (service)` grouped by a constant and drew a single line\nlabelled \"evenkeel\" however many providers there were. The panels still\nrendered.\n\n## A second transport, over the same use cases\n\n`presentation/mcp` exposes the wallet as [MCP](https://modelcontextprotocol.io)\ntools, so a model can hold an account. Six tools, each resolving the *same*\ninteractor the HTTP router resolves:\n\n```bash\nAPP__MCP__OWNER_ID=<uuid> make run-mcp\n```\n\nNothing below `presentation` changed to add it — no new port, no argument on a\ncommand, no branch in a use case. The idempotency key, the per-wallet lock, the\noptimistic version, the rate limit and the outbound risk check all apply to a\ntool call because none of them ever lived in the HTTP layer. That is the only\nhonest way to show the layering is real rather than decorative, and it is why\nthis exists.\n\n**The owner is configuration, never a tool argument.** A\n`deposit(owner_id, ...)` tool would be a cross-tenant IDOR with a\nnatural-language interface: the model reads untrusted text for a living, so\n\"it was persuaded to pass a different id\" is a normal Tuesday. The parameter\nthat does not exist cannot be manipulated, and a test asserts on the published\nschemas that none of them has one.\n\nAdding this surfaced three defects that one transport had been hiding — an\namount larger than the ledger column can store, `Money(NaN)` raising the wrong\nerror, and test fakes that disagreed with the database about row order. All\nthree are fixed in the domain and the fakes, not in the tool layer.\n[ADR 7](docs/adr/0007-mcp-as-a-second-transport.md) has the reasoning.\n\n## API reference\n\nThree renderings of the same document, on a local run only:\n\n| | |\n| --- | --- |\n| `/scalar` | search, a working request client, dark mode |\n| `/docs` | Swagger UI |\n| `/redoc` | ReDoc |\n\n`make docs` builds the same reference as a static page — the deploy refuses to\npublish a document that disagrees with the application, so a page that ships is\nalso proof the spec is current. The CDN script is version-pinned with an\nintegrity hash, checked against the file actually served by\n`tests/unit/test_docs_page.py`.\n\n`openapi.json` is committed. CI regenerates it and fails if it drifts — the same\ntreatment migrations get — and runs [oasdiff](https://github.com/oasdiff/oasdiff)\non pull requests so a breaking change to the contract is a red check rather than\na surprise for whoever built against it.\n\n## How the tests are organised\n\nNot a pyramid. Two questions, asked separately:\n\n| Directory | Question it answers | Needs |\n| --- | --- | --- |\n| `tests/unit` | Do the domain rules and the use-case policy hold? | nothing |\n| `tests/contracts` | Does every adapter of a port behave the same? | Redis, optionally |\n| `tests/http` | Does the application behave correctly end to end? | nothing |\n| `tests/integration` | Does the SQL do what the fake pretends it does? | PostgreSQL |\n\nThe split that matters is not unit-versus-integration but *architectural part*\nversus *application as a black box*. `unit` and `contracts` test parts; `http`\nand `integration` test the thing itself. Testcontainers made the second half\ncheap enough that pushing everything into mocks now buys speed by giving up the\nonly tests that would have caught a broken adapter.\n\n`make test` runs everything that needs no service; `make test-integration` runs\nthe rest.\n\n## Security, with receipts\n\n[`docs/SECURITY_CONTROLS.md`](docs/SECURITY_CONTROLS.md) is 51 controls, one row\neach: the claim, where it is enforced, and the test that proves it, tagged with\nits CWE. [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) is the reasoning behind\nthem — assets, trust boundaries, and a section on what is deliberately *not*\ndefended, because a threat model listing only wins is marketing.\n\nThe two are one argument split in half, so they are checked against each other:\nevery test the matrix names must exist and carry the CWE assigned to it, every\n`@pytest.mark.cwe` test must be cited, and every CWE in the threat model must\nhave a row. That check earned itself immediately — the matrix was citing a test\nrenamed some commits earlier, a receipt pointing at nothing.\n\n```bash\nuv run pytest -m cwe        # every test that is somebody's receipt\n```\n\n## For agents\n\n[`AGENTS.md`](AGENTS.md) is the rule set an agent gets before it edits anything:\nthe layer table, a \"task → files, in order\" map, DO/DON'T for the rules with\nteeth, an anti-pattern table of every trap this build actually hit, and the list\nof changes that must stop and ask a human. `CLAUDE.md` points at it, so both\nconventions resolve to one file.\n\nMost of those rules are a command rather than a paragraph — `make arch` fails on\na crossed layer, `make types` on an adapter that drifted from its port,\n`make schema-check` on an HTTP contract that changed without the document\nchanging with it. Two conventions became checks while this was written: every\nadapter must be named in `conformance.py`\n(`tests/unit/test_adapter_conformance.py`), and the scaffolder's own output must\npass the project's lint and resolve every symbol it imports\n(`tests/unit/test_new_vertical.py`).\n\n```bash\nmake new-vertical NAME=orders\n```\n\nwrites the interactor, schema, router and test in the right layers and prints\nthe three edits it deliberately does not make for you.\n\n## Development\n\n`make` on its own prints the grouped, coloured list; the whole surface is\ntwenty targets in five groups.\n\n```bash\nmake sync      # install\nmake check     # every gate, with a pass/fail summary and per-gate timings\nmake run       # start the API\nmake run-mcp   # start the MCP server on stdio\nmake demo      # re-record docs/demo.gif from the tape\nmake load      # drive the API under k6\n```\n\n`make check` runs the same list CI does and, like the CI gate job, runs every\ngate even after one fails — being told about one broken thing at a time costs a\nfull rerun to find the next. Colour follows [NO_COLOR](https://no-color.org) and\nswitches itself off when the output is not a terminal, so a CI log does not fill\nwith escape codes.\n\n[CONTRIBUTING.md](CONTRIBUTING.md) is what a change has to clear to land here —\na test you have watched fail, a receipt for every claim, and a number that comes\nfrom a command. Vulnerabilities go through [SECURITY.md](SECURITY.md) rather\nthan an issue.\n\n## Status\n\nIn place: the core and the wallet slice, the CI pipeline the badge points at,\nthe outbound-call slice with its load runs, Prometheus metrics behind\n`/metrics` with a Grafana dashboard and alert rules over them, the MCP\ntransport, JWT/JWKS verification against any OIDC issuer, and the OpenAPI\ncontract with its drift and breaking-change checks.\n\nNot started, and deliberately not listed above as if they were: tracing, an\noutbox, a worker. The reasoning behind what is here\nlives in `docs/adr/`.\n\n## License\n\nMIT\n"}