The incident shape

Picture an orders service on a busy afternoon. Traffic climbs, and the Kubernetes Horizontal Pod Autoscaler (HPA) adds pods, exactly as configured. Each new pod opens its own pool of connections to PostgreSQL. A few pods into the scale-up, the database starts refusing new connections. Requests fail, and every pod the autoscaler adds from here asks for connections that do not exist.

Nothing in that story is a Kubernetes defect or a PostgreSQL defect. The autoscaler scaled. The database protected itself by enforcing its limit. Each layer passed its own checks. The failure lives in a number nobody owns: how many connections the whole deployment can open, against how many the database will accept.

Site reliability engineers (SREs) and platform engineers know this failure well. Two current changes add new ways to trigger it. Autoscaling changes replica counts without a person in the loop, and coding agents can now edit Deployment manifests, HPA objects and pool settings in the same change as application code, without anyone re-running the arithmetic.

What each layer guarantees

Kubernetes documents the HPA as a control loop. By default the controller runs every 15 seconds, compares the current metric with the target, and computes the desired replica count from their ratio. It never goes above the maximum you configure. Scaling up has no stabilization window by default: the controller may add 4 pods or double the running count every 15 seconds, whichever is more. Its inputs are CPU, memory, custom and external metrics. The database's connection limit is not one of them unless you build that metric yourself.

A rolling update adds a second multiplier. A Deployment's maxSurge setting allows extra pods above the desired count during a rollout, and its default is 25%, rounded up.

PostgreSQL enforces its own ceiling. In the PostgreSQL 18 documentation, max_connections defaults to "typically 100", and you can only change it at server start. Part of that number is not yours. superuser_reserved_connections holds back 3 slots by default for superusers, and reserved_connections (default 0) can hold back more for roles granted pg_use_reserved_connections. Once free slots fall to those reserves, the server admits only those roles.

Both systems behave as documented. The HPA object has no field for the database limit, and PostgreSQL has no idea how many pods exist.

The connection budget, worked through

The figures below are hypothetical. They show the shape of the calculation, not a measured system.

Input (hypothetical)ValueWhere it lives
HPA maximum replicas12HPA object
Connection pool size per pod12Application config
Potential connections at maximum scale12 × 12 = 144Nowhere
PostgreSQL max_connections100Database config
Left after 3 superuser-reserved slots97Derived
Pods during a rollout at maximum (25% surge)12 + 3 = 15, so 180 connectionsDeployment strategy

At full scale the service can ask for 144 connections from a database that accepts 97 ordinary ones, and 180 during a rollout. That is before migration jobs, scheduled workers, admin sessions or a second service that shares the database take their share. Their numbers live in other files, or in nobody's file.

Run the arithmetic the other way and the decision becomes concrete. With 12 connections per pod and 97 slots, at most 8 pods fit: 8 replicas use 96 connections. Count the 25% rollout surge as well and the HPA maximum drops to 6 (6 plus 2 surge pods is 8). Cut the pool to 8 connections per pod and the cap rises to 9, surge included (9 plus 3 surge pods is 12, using 96 connections). Either answer is a trade-off a team has to choose and write down.

What a pooler changes

A connection pooler such as PgBouncer changes the terms. It does not remove the budget. Pods connect to PgBouncer, and PgBouncer holds a smaller set of server connections to PostgreSQL. In transaction pooling, a client holds a server connection only for the length of a transaction.

The budget now has three terms instead of one. Server connections are bounded per user and database pair by default_pool_size (default 20), and per database by max_db_connections, which defaults to 0, meaning unlimited. Client connections are bounded by max_client_conn, default 100. The hypothetical 144 client connections would exceed that default on a single PgBouncer instance. Run three PgBouncer replicas with a pool of 20 each for one user and database, and PostgreSQL sees up to 60 server connections from them, plus anything that bypasses the pooler.

Transaction pooling also constrains the application. PgBouncer's feature map lists session features that transaction mode does not support, including SET/RESET, LISTEN and session-level advisory locks. Choosing the pooling mode is a decision the code has to respect too.

The budget is an architecture decision

A connection budget has the properties of an architecture decision: a reason, an owner, a scope and consequences across several components. Written down, it reads something like this:

  • The orders service reaches PostgreSQL only through its data adapter: the one module that opens database connections and owns the connection pool.
  • Potential server connections from every client, including rollout surge, stay below the database limit minus reserved slots.
  • The service connects through PgBouncer in transaction mode and does not depend on session state.

Once the decision exists as a record, each part has a home. Architecture as Code describes the intent in a form tools can read. Policy as Code turns parts of it into rules that engines evaluate. Keeping those rules tied to the current version of the decision is a lifecycle problem of its own, covered in Policy as Code Enforces Rules. Who Governs the Decisions Behind Them?

Static controls and runtime controls

Some parts of the budget are deterministic: you can compute them from files before anything deploys. Others only exist at runtime. A static check cannot see a client that leaks connections, and a dashboard cannot stop a bad manifest from deploying, so the budget needs both kinds.

ControlKindEnforced by
Application code reaches the database only through the adapterStatic, deterministicMneme, an open-source tool that enforces recorded decisions on application code (shipped, with limits below)
HPA maximum × pool size, plus surge, stays within the budgetStatic, deterministicA continuous integration (CI) step over rendered manifests (the final Kubernetes objects after templating), or a policy engine such as Open Policy Agent (OPA) or the command-line tool of Kyverno, a Kubernetes policy engine
The cluster rejects a manifest that breaks the budgetStatic, at admissionAn admission controller (a hook that approves or rejects objects before the cluster stores them), such as Kyverno or OPA Gatekeeper
PgBouncer pool sizes and mode match the decisionStatic, deterministicReview and CI on the pooler config
Connections in use, other clients, leaksRuntime, measuredMonitoring on pg_stat_activity and pooler stats

The static check proves the deployment cannot request more than the budget on paper. It cannot prove what is open right now. PostgreSQL's pg_stat_activity view shows one row per server process, and that is where a forgotten cron job or a leaking client appears. You need both: arithmetic before deploy, measurement after.

The arithmetic check has to see the HPA object, the Deployment, the application's pool setting and the database limit together. Rendered manifests in CI give it all four in one place. Kyverno, which runs as a dynamic admission controller, and Gatekeeper, a validating and mutating webhook that enforces policies executed by Open Policy Agent (OPA), are the right tools when the cluster itself must refuse the change.

Where Mneme fits, and where it does not

Mneme is an open-source decision and control layer for agentic software development, and it enforces the first row today, in the agent and in CI on every pull request, in warn or strict mode. The decision lives in an architecture decision record (ADR) with a typed rule: a constraint written in a fixed format that Mneme can evaluate mechanically. This one forbids the direct client import inside request handlers:

## Constraints
- FORBID_LITERAL:
    value: from app.db.client import
    include_paths:
      - "app/handlers/**"

Mneme returns one of three verdicts: PASS, WARN or FAIL. A handler that contains that text gets FAIL, the strongest verdict (exit code 2 in strict mode; warn mode reports it without failing the job), and the result names the decision. Mneme runs the same check before a supported agent's built-in file edit is saved (for example Claude Code's Edit and Write tools, as opposed to shell commands), and on pull requests in CI.

This rule matters to the budget because the pool size only bounds connections if every query goes through the pool. A handler that opens its own client adds connections no manifest declares, and the static arithmetic above becomes wrong without any infrastructure file changing.

The rule has documented limits. FORBID_LITERAL matches exact text, so import app.db.client as db passes; you add one rule per import form or run an import linter beside it.

No Mneme rule type checks replica counts and pool sizes against a connection budget. Doing that from a recorded decision is a concept, not a shipped feature. Mneme does not enforce infrastructure policy or admit deployments; Kyverno, OPA and similar tools do that work.

One decision, several enforcement points

The budget decision feeds at least four enforcement points: the code rule, the CI arithmetic, the admission policy and the alert. When one fails, the on-call engineer should be able to answer which decision it protects, which version, which rule fired, on which artifact, and with what verdict. That is the evidence question engineering decision governance asks.

Part of that chain ships today. A Mneme violation names the decision and rule behind it, and the Decision Index, the versioned record of decisions that Mneme keeps in the repository's .mneme/project_memory.json file, records each decision's versions, lifecycle and source. Linking the CI budget check, the Kyverno policy and the alert to the same decision version is something teams do by convention, for example by citing the ADR identifier in each policy. Mneme does not link them across layers for you.

The urgency comes from who edits the numbers. An agent that raises an HPA maximum from 12 to 20 to fix a latency ticket has made a reasonable local change. The budget breaks in a file it never opened. As When Agents Launch the Database argued, agents now act on infrastructure directly, so the decision has to be enforceable wherever they act.

What to do now

  • Write the budget down. Record the arithmetic as a decision: database limit, reserved slots, other clients, pool size per pod, HPA maximum and surge.
  • Compute it in CI. Fail the build when rendered manifests can request more than the budget.
  • Add admission only where you need it. Use Kyverno or Gatekeeper when the cluster must refuse the change, not only the pipeline.
  • Measure the runtime side. Alert on connections in use against the same budget, so the static number and the observed one stay comparable.
  • Hold the code boundary. Enforce the adapter rule in application code, or the pool size stops meaning anything.

Frequently asked questions

Can the Kubernetes HPA scale past what PostgreSQL accepts?+
Yes. The Horizontal Pod Autoscaler scales on metrics such as CPU and stops at the maximum replica count you configure. It does not read the database's connection limit. If the maximum replica count times each pod's pool size exceeds PostgreSQL's max_connections, minus reserved slots, the autoscaler can add pods the database will refuse.
How do you calculate a database connection budget?+
Start with max_connections and subtract reserved slots (superuser_reserved_connections defaults to 3). Subtract every other client: jobs, workers, admin sessions, other services. Divide what is left by the pool size per pod to get the most pods that fit, then allow for rollout surge, which defaults to 25% on a Deployment. In a hypothetical case, 12 replicas with a pool of 12 need 144 connections against 97 available (a limit of 100 minus 3 reserved slots).
Does PgBouncer remove the connection limit problem?+
No, it changes the terms. In transaction pooling, PgBouncer gives a client a server connection only for the length of a transaction, so many clients share fewer server connections. The budget then covers PgBouncer's server pools (default_pool_size, max_db_connections) and its client limit (max_client_conn, default 100), and the application must avoid session features that transaction mode does not support.
Does Mneme enforce Kubernetes or database connection limits?+
No. Mneme enforces typed rules on application code, such as a path-scoped FORBID_LITERAL rule that fails a request handler importing the database client directly. It does not enforce infrastructure policy, admit deployments, or check replica counts against a connection budget. Admission control belongs to tools such as Kyverno and OPA Gatekeeper, and the runtime side belongs to monitoring.