Contents
  1. How to read this handbook
  2. Contents
  3. 1 · The anatomy of a modern service repository
  4. 2 · The system end to end
  5. 3 · Linux and processes
  6. 4 · Networks: DNS, TCP, TLS and HTTP
  7. 5 · Concurrency and the Python backend
  8. 6 · Postgres: schema changes and indexes
  9. 7 · Connections, pooling and row-level security
  10. 8 · Sign-in, sessions and roles
  11. 9 · Secrets, personal data and least privilege
  12. 10 · Frontend decisions and streaming
  13. 11 · The edge: TLS, nginx and proxies
  14. 12 · Containers and Compose
  15. 13 · Testing, code style and review
  16. 14 · Observability and debugging
  17. 15 · Reliability: timeouts, failures and drills
  18. 16 · Performance and load testing
  19. 17 · Incidents, runbooks and service levels
  20. 18 · The stack catalog
  21. 19 · Starting a new service
  22. A · Glossary
  23. B · Decision index
  24. C · Repository index
  25. D · Trap index

Engineering Handbooks

Handbook 2 of 3Modern Software Engineering

Your skills, brought up to date: how a production service and its repository are built today.

For engineers who have built and run services before and are returning to the work. It assumes you know what a database, a web server and a test are, and shows how each is done now, why it changed, and where to see it working in a real, public codebase.

Edition 1 · Reference implementation: triage-assistant at handbook-1 (unknown)

How to read this handbook

Every chapter is anchored to one real system: triage-assistant, an on-call assistant built as the template a team starts its AI services from. Alerts live in Postgres, owned by teams. People sign in with the organization's identity provider and see only their teams' alerts. A model answers their questions from those alerts and the team's runbooks, and cites what it used. The service was load-tested, broken on purpose, and deployed to AWS through a real pipeline. Its code is public at github.com/Parzon/triage-assistant.

Numbers in this handbook come from that system unless the text says otherwise, and each is given with the setup it was measured on. Code links point to the exact version this edition was checked against, so the line numbers beside an excerpt are the ones you will see on GitHub.

Three ways through this handbook, depending on what you need first:

If you want toRead
Find your way around a modern serviceChapters 1, 2, 12 and 19: the repository, the system, containers, starting a new service
Refresh the foundationsChapters 3 to 9: Linux, networks, concurrency, Postgres, pooling, sign-in, secrets
Run it in productionChapters 11 and 14 to 17: the edge, observability, reliability, performance, incidents

Each chapter has the same parts, so you can read at the depth you need:

In one sentenceWhat the topic is, in words you can use in a meeting.
At a glanceThe points to keep if you read nothing else.
Then and nowHow it was usually done, how it is done today, and why it changed.
FiguresThe mechanism, drawn. The text explains the figure.
In the repoWhere the idea lives in the reference code, with the lines quoted beside the explanation.
How to say itA few sentences for a design review.
TrapsWhat goes wrong in practice, and the fix.
Going deeperDetail for when you need it. Collapsed on the web, printed in full.
QuestionsTo check your understanding. Answers follow.

Contents

1

Part I · The service and its repository

The anatomy of a modern service repository

A modern service repository holds far more than source code: the commands to run it, its environments, its quality gates, its decisions, its emergency procedures and its automation all live beside the code, versioned and reviewed the same way.

At a glance
  • The repository is the service's documentation, configuration and operating manual, not only its source.
  • Every kind of file has one job: a way in for newcomers, a contract for AI coding tools, decision records, runbooks, one command interface, an environment overlay per use, a configuration contract.
  • Quality gates are code too: pipelines, scanners with expiring exceptions, alert rules with unit tests, dashboards generated from code.
  • Documents have lifetimes: a product requirements document lives until the feature ships, a proposal until it is decided, a decision record forever.
  • One shape across projects means an engineer joining the seventh project already knows where everything is.
Then and now
ThenNowWhy it changed
A README; design notes on a wiki; decisions in email threadsDocs as code: decision records, runbooks and handbook chapters in the repository, reviewed in pull requestsA wiki drifts away from the code; the repository changes with it.
"Install Python, Postgres and Node, then follow these 20 steps"Every toolchain in containers, behind one command interfaceThe image is the environment, so "works on my machine" disappears.
A config file per environment, often with secrets in itA configuration contract (.env.example) and values from the environment; production refuses unsafe onesSecrets stay out of Git, and bad configuration fails at startup, not at 3 a.m.
Build jobs configured in a server's web interfacePipelines as YAML files in the repositoryReviewable, reproducible, and versioned with the code they build.
Monitoring configured by hand in a dashboard toolAlert rules with unit tests; dashboards generated by codeChanges are reviewed, and nothing is lost when a tool reloads.
Conventions held in senior engineers' headsAGENTS.md, code owners, a pull request template, lint and type checksHumans and AI coding agents read and obey the same written contract.

The map

The reference repository is grouped below by purpose rather than by folder. Each group answers one question a newcomer asks.

THE WAY IN: WHERE DO I START? README.mdwhat it is, how to run it START_HERE.mdfour reading levels docs/code-map.mda line per file AGENTS.mdrules for AI tools CONTRIBUTING.mdworkflow, done THE SERVICE: WHAT DOES IT DO? apps/api/app/the service code apps/api/migrations/schema apps/api/tests/unit, integration apps/api/evals/model quality apps/web/the React app tools/mock model, HTTPS edge HOW IT RUNS: HOW DO I RUN IT? compose*.yamlbase + overlays */Dockerfiledev and prod Makefileevery command .env.exampleconfig contract infra/monitoring, VM, AWS scripts/deploy, backup, drills QUALITY GATES: HOW IS A CHANGE CHECKED? .github/workflows/CI, release, weekly scan, deploy .github/dependabot.ymlupdate pull requests .github/CODEOWNERSwho must approve what pull_request_template.mdevidence asked for .trivyignore.yamlaccepted risks, expiring tests/e2e, tests/loadbrowser and load tests KNOWLEDGE: WHY IS IT LIKE THIS? docs/adr/28 decision records docs/prd/, docs/rfc/what and why; how docs/runbooks/procedures under pressure docs/handbook/how it works here docs/privacy.mdpersonal data held TECH_STACK.md, the guidechoices and traps
Figure 1.1The reference repository, grouped by the question each part answers. Nothing here is unusual on its own; what matters is that every part exists, has one job, and is kept current as the code changes.

The way in

A newcomer should go from cloning the repository to a running stack in under an hour, ideally with one command, and know what to read next. Four files carry that:

  • README says what the service is and how to run it.
  • START_HERE is a reading path in four levels: how one question gets answered (the core), the production shape, operations, and the AI depth. Each level says what to read, what to run, and what you can explain afterwards.
  • The code map gives one line per file, saying what it is for, with a star on every file a user's question passes through, in the order it passes.
  • AGENTS.md is the contract for AI coding tools such as Claude Code and Codex: the commands, the conventions, and what they must not do. Tools read it automatically from the repository root; when an agent gets something wrong twice, the fix belongs in this file.
In the repodocs/code-map.mdandAGENTS.md

Two files a newcomer and an AI agent read first. The code map is a table, one row per file; the stars trace a question from the HTTP route to the model and back. AGENTS.md ends with prohibitions, each one there because something went wrong once.

  • The code map's job is "what is this file for", not how it works: that keeps it short enough to stay current.
  • The prohibitions protect the team's controls from a helpful agent: no editing a merged decision record, no exempting a scan finding to get green, no weakening an eval case to make a run pass.
| File | What it is for |
|---|---|
| `asgi.py` | the entrypoint gunicorn and uvicorn load; process-wide setup (logging, tracing) |
| `main.py` | the application factory: creates the engine, clients and settings in the lifespan, mounts the routes |
| `config.py` | every setting, read from the environment and checked at startup; production refuses unsafe ones (ADR-0023) |
| `routes/chat.py` ★ | `POST /chat/stream`: checks the rate limit, then the off switch; gathers the context, streams the answer |
| `queries.py` ★ | the reads shared by routes: the alert list and the chat's context use the same visibility rule |
| `runbooks.py` ★ | runbooks split into sections, embedded, and searched (full-text and vector, the asker's teams only) |
| `redact.py` ★ | removes credentials from alerts, runbooks and questions before any model sees them |
| `triage.py` ★ | the prompt (`SYSTEM_PROMPT`), what the model is given, and its stream turned into client events |
| `llm.py` ★ | the seam to any OpenAI-compatible model: streaming, timeouts, typed errors, token counts |
| `sse.py` ★ | the Server-Sent Events wire format: one JSON line per event, heartbeats |
## What agents should NOT do
- Don't modify `infra/` without being explicitly asked.
- Don't add a new third-party service/dependency without flagging it in
  the PR description first.
- Don't edit a merged `docs/adr/*.md` file — write a new ADR that
  supersedes it instead.
- Don't make a scan pass by exempting its finding. An entry in
  `.trivyignore.yaml` needs a statement (why, who accepted) and an expiry
  at most 90 days out; a line in `.gitleaks.toml` or `.gitleaksignore`
  needs the value shown to be fake. A real secret is rotated first.
- Don't weaken, delete or re-label an eval case, a calibration answer or a
  retrieval question to make a run pass. A failing case is a claim to investigate: read the
  answer and the judge's reason, and change a check only when the check is
  shown wrong, in its own commit, saying why in the case's `notes`.

Documents, by job and lifetime

A team writes several kinds of document, and confusing them is how documentation rots. Each answers a different question, for a different reader, and lives for a different length of time.

IDEA DESIGN BUILD OPERATE PRDwhat are we building, for whom, why? until shipped, then archived RFCshould we, and roughly how? during review, then archived ADRwhat did we decide, and why? One per decision permanent: superseded by a new one, never edited Runbooks, handbook, gotchas how to act under pressure; how it works here; traps. Kept current
Figure 1.2Documents by job and lifetime. Not every change needs one: a one-line fix needs none. The test for a decision record is simple: if you would have to explain a decision in chat more than once, it should have been written down.
When you…Write
are proposing what to build, and whya PRD: problem, goals, non-goals, measurable success, user stories
want agreement on an approach before buildingan RFC: summary, motivation, approach, alternatives, trade-offs, rollout
made a decision someone will later ask "why?" about: a dependency, a schema shape, a timeout, a deployment patternan ADR
found a procedure someone will need under pressurea runbook
learned something the next person will trip overa line in the handbook chapter, and in the list of traps

Architecture decision records

An architecture decision record (ADR) is a one-page record of one decision: the context that forced it, the decision, the alternatives considered and rejected, and the consequences, good and bad. It is written when the decision is made and never edited afterwards. When the decision changes, a new ADR supersedes the old one, which keeps the history of why the system is the way it is. Teams keep them so that the next engineer, or the infrastructure team inheriting the service, can learn the reasoning without asking around.

ADR-0015: Rollbacks roll back… Status: accepted Contextwhat problem forced it Decisionwhat we are doing Alternativeswhat else, and why not Consequenceseasier, harder, measured proposed accepted supersededby ADR-N A real status line ADR-0001: accepted; its streaming format superseded by ADR-0007, its compose layout by ADR-0003 The old text stays. The new record explains what changed, so the history reads in order.
Figure 1.3Anatomy and lifecycle of a decision record. The alternatives section is the part future readers value most: it answers "why didn't you just…?" before anyone asks.

The reference repository holds 26 ADRs, and most of them can be seen at work in the code. Take ADR-0015: it decides that a rollback deploys the old code and never reverses the database schema. You can see it carried out in the deploy script: before migrating, it asks whether the image knows the database's current revision; if not, the database is ahead, so this is a rollback, and it runs the older code on the newer schema without migrating.

In the reposcripts/deploy.shthe decision, carried out on a single host

The comment restates the decision record in the place a future editor will read it. The code asks the database which revision it is on, then asks this release's migration tool whether it knows that revision.

  • If not, the database is ahead: "rolling back the code only, no migration".
  • Only that one message means "ahead". Any other failure, such as Docker being unavailable or an image that does not start, stops the deploy. Reading every failure as a rollback would skip a migration the new code needs.
  • The comment also states the limit: safe down to the release before a contract migration, never past it, with the real versions as the example.
# A rollback: the database already carries a newer release's migrations,
# which this image's Alembic has never heard of ("Can't locate revision").
# Run this release's code on the newer schema, without migrating. That is
# safe only if this release can run on it - down to the release before a
# contract migration, never past it (v0.3.0 -> v0.2.0 yes, -> v0.1.0 no).
# Migrations are never reversed automatically: `alembic downgrade` is a
# deliberate, separate step.
current=$("${COMPOSE[@]}" exec -T db sh -c \
  'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT version_num FROM alembic_version" 2>/dev/null' || true)
ahead=no
if [ -n "$current" ] && ! shown=$("${COMPOSE[@]}" run --rm --no-deps -T migrate alembic show "$current" 2>&1); then
  # Only this message means "a newer release migrated it". Any other failure
  # (Docker unavailable, an image that does not start) is a failed deploy,
  # not a rollback: reading it as one would skip the migration.
  if ! grep -q "Can't locate revision" <<< "$shown"; then
    printf '%s\n' "$shown" >&2
    echo "could not check the database's revision against $TAG: deploy stopped" >&2
    exit 1
  fi
  ahead=yes
fi
if [ "$ahead" = yes ]; then
  step "the database ($current) is ahead of $TAG: rolling back the code only, no migration"
else
  step "migrate"
  "${COMPOSE[@]}" run --rm migrate
fi

Configuration as a contract

Twelve-factor applications take their configuration from the environment, and the reference service adds two safeguards around that. .env.example is committed: it lists what a first run sets, with placeholders and comments, and the settings reference in the repository's docs lists every variable, with its default. The real .env is generated from it with fresh secrets and is never committed. At startup, typed settings are validated, and in production mode the service refuses to start on unsafe values, naming each problem.

.env.examplecommitted: thecontract .envsecrets generated;never committed Composepasses only what aservice lists Typed settingsvalidated atstartup Refusesin production, onunsafe values The trap a lint check now closes 22 settings could not be changed from .env: Compose never passed them, and nothing said so
Figure 1.4From a committed contract to a running service. The failure modes are silent ones: a variable that never reaches the container, a demo value that reaches production. Each has a check that runs before anything serves traffic.
In the repoapps/api/app/config.pywhat production refuses

The production checks are a function that returns every problem by name, each with a message that says what is wrong and how to fix it. Startup fails if any is present, unless a deployment waives a check by name, explicitly.

  • A public URL that is really localhost; session cookies over plain HTTP; the fake model; the demo identity provider.
  • A security property is a test, not a checklist line: each refusal has a unit test.
  • The reference AWS stage, with no domain and no paid model, ran with exactly two named waivers.
def production_problems(settings: Settings) -> dict[str, str]:
    """The production checks these settings fail: name -> what is wrong."""
    problems: dict[str, str] = {}
    public = _host(settings.public_url)
    if public in _LOCAL_HOSTS or public.endswith(".localhost"):
        problems["localhost_url"] = (
            f"PUBLIC_URL is {settings.public_url}: no other machine reaches it, and "
            "sign-in redirects there. Set the address users open."
        )
    if not settings.session_cookie_secure or settings.public_url.startswith("http://"):
        problems["insecure_cookies"] = (
            "session cookies would travel over plain HTTP: PUBLIC_URL must be https:// "
            "and SESSION_COOKIE_SECURE true."
        )
    if (
        _host(settings.llm_base_url) == "mock-llm"
        or settings.llm_model.startswith("mock")
        or (settings.embedding_model or "").startswith("mock")
    ):
        problems["mock_model"] = (
            "LLM_* or EMBEDDING_MODEL name the mock: set the provider's, and drop "
            '"mock" from COMPOSE_PROFILES.'
        )
    if _host(settings.oidc_metadata_url) == "keycloak":
        problems["demo_identity_provider"] = (
            "sign-in goes to the bundled Keycloak and its demo users: set OIDC_* to the "
            'organisation\'s provider, and drop "idp" from COMPOSE_PROFILES.'
        )

One command interface, and an overlay per environment

Every toolchain runs in a container, so the host needs only Docker, make and Git. A Makefile is the single entry point: its targets are thin wrappers over the real container commands, so anyone can read a recipe and run the underlying command directly. Running the default target lists the commands a newcomer needs in their first weeks.

Environments are Compose overlays on one shared base file, so development, tests and the production shape cannot drift apart without it showing in a diff.

compose.yaml every service, shared by all environments; never run alone compose.overridedevelopment, mergedautomatically: hot reload,ports on localhost only compose.prodrelease images, limits,read-only containers,only the edge published compose.testa throwaway stack underits own project name,deleted after the run compose.debugthe api under adebugger the editorcan attach to
Figure 1.5One base, four overlays. Merging a base with an overlay is ordinary Compose behavior; the discipline is that environment differences live only in the overlays, where a reviewer can see them.

Quality gates, as code

Every check a change must pass is a file in the repository, and so is every exception to a check.

  • Pipelines: CI on every pull request, a release on every version tag, a weekly scan, a deploy (Handbook 3).
  • Review rules: code owners name who must approve each path, and the pull request template asks for the evidence.
  • Updates: Dependabot opens grouped update pull requests for packages, actions and base images.
  • Exceptions with expiry: accepted vulnerabilities and known fake secrets live in files that code owners must approve, and accepted risks expire within 90 days.
  • Monitoring: alert rules ship with unit tests that feed synthetic metrics and assert which alerts fire; dashboards are generated by a script, so an edit made in the dashboard tool cannot be lost on reload.
In the repoinfra/observability/prometheus/alerts.test.ymlan alert rule, unit-tested

A unit test for an alert rule. It feeds two synthetic time series, one api worker losing the identity provider and one not, and asserts that the alert has not fired at 90 seconds but has fired, with the right labels and runbook link, at four minutes.

  • The test documents the rule's behavior: "fires after two minutes, on any worker".
  • Every alert carries a link to its section of the alert runbook, so the person paged lands on the procedure.
  • CI runs these tests on every pull request, like any other test.
tests:
  - name: An unreachable identity provider fires after two minutes, on any worker
    interval: 30s
    input_series:
      - series: 'identity_provider_up{instance="api-1"}'
        values: "1 1 0x10"
      - series: 'identity_provider_up{instance="api-2"}'
        values: "1x12"
    alert_rule_test:
      - eval_time: 1m30s
        alertname: IdentityProviderDown
        exp_alerts: []
      - eval_time: 4m
        alertname: IdentityProviderDown
        exp_alerts:
          - exp_labels: {severity: high}
            exp_annotations:
              summary: "the identity provider does not answer: new sign-ins fail (signed-in users are unaffected)"
              runbook_url: "https://github.com/Parzon/triage-assistant/blob/main/docs/runbooks/alerts.md#identityproviderdown"

One shape for every project

The reference repository is also a template, and its first decision record explains why. The team's job is proving AI capabilities; services that prove out are handed to infrastructure engineers to run. For that handover to be fast, everything that is not the AI logic itself should look the same from project to project, so an engineer inheriting the seventh project already knows where everything is. Chapter 19 covers starting a new service from it: what to keep, what to replace and what to configure.

Traps

SymptomCauseFix
A setting changed in .env has no effectCompose passes a variable to a container only if that service lists itList every setting with no value under environment:; check it in lint.
Two stacks in one directory interfere: the dev api reports a release's version.env is shared by every Compose project in the directoryGive the second stack its own ports and project name on the command line.
The development overrides silently stop applyingPassing any -f turns off the automatic merge of compose.override.yamlList it explicitly together with the other overlay.
A service enabled by a profile disappears--profile X replaces the profile list from .envSet the full list instead.
An empty setting crashes a server at importAn empty variable is not an unset oneTreat empty as unset in configuration code, and test it.
A dashboard change made in the tool is goneDashboards are regenerated from codeChange the generator, then regenerate.
A merged decision record was quietly rewrittenEditing instead of supersedingWrite a new record that supersedes the old; protect the folder with code owners.

Questions

  1. What is the difference between a PRD, an RFC and an ADR, and how long does each live?

    Answer

    A PRD says what to build and why, and lives until the feature ships. An RFC proposes how, and lives through its review. An ADR records one decision and its reasons, and is permanent: superseded by a later ADR, never edited.

  2. Why is a merged decision record never edited?

    Answer

    Because its value is the record of what was decided and why at the time. A new record that supersedes it keeps both the old reasoning and the reason for the change, so the history reads in order.

  3. Why does .env.example belong in Git while .env never does?

    Answer

    .env.example is the contract: what a first run sets, with placeholders and explanations, and no secrets (the settings reference lists every variable and its default). .env holds real values and secrets for one machine, generated from the example.

  4. What do code owners add to branch protection?

    Answer

    They say whose approval each path needs, so a change to migrations, infrastructure, decision records or accepted risks cannot merge without the people responsible for them.

  5. Why unit-test alert rules?

    Answer

    An alert that never fires, or fires on a blip, is discovered during an incident. A test with synthetic metrics proves when it fires and with which labels, and documents the rule's intent.

2

Part I · The service and its repository

The system end to end

The reference service is a modular monolith (one api, one database, a web app and an edge proxy), and following a single question from the browser to the model and back explains almost every design choice in it.

At a glance
  • Six parts in the production shape: an HTTPS edge, nginx serving the web app, the api, a connection pooler, Postgres with vector search, and Valkey for shared counters. Outside it: the identity provider and the model.
  • Four request paths matter: signing in, reading alerts, asking the assistant, and the alert webhook.
  • A question passes through about eight small modules, each with one job, in a fixed order.
  • The api is one deployable on purpose. The first splits, when they come, are separate deployments of the same code, not separate codebases.
  • The api is stateless: sessions live in Postgres and counters in Valkey, so any replica serves any request.
Then and now
ThenNowWhy it changed
A three-tier application: web, application, databaseThe same tiers, plus an identity provider and a model behind narrow seamsSign-in and AI became external services with their own failure modes.
Microservices from day oneA modular monolith until a measured need appearsEvery network hop adds failure modes, latency and operations work.
Server-rendered pages with sticky sessionsA static single-page app, a stateless api, state in shared storesReplicas can be added and removed freely.
Request, wait, full responseStreamed answers, token by tokenA model takes seconds; users should see the first words, not a spinner.

The system on one page

Browser React app ONE HOST OR PLATFORM, ONE DEPLOYMENT Edge (Caddy) HTTPS, certificates nginx (web) static build; /api api FastAPI under gunicorn, one worker per CPU session → user → teams Identityprovider (OIDC) The modelOpenAI-compatible PgBouncertransaction pooling Postgres+ pgvector; data,sessions, audit Valkeyshared counters Monitoringoptional 443 scrapes the api's /metrics sign-in pages: /auth goes to the provider
Figure 2.1The production shape. The same images run on a laptop, a VM and ECS; only configuration changes. In production the organization's identity provider replaces the bundled one, with no code change.

Four request paths cover almost everything the service does:

  • Sign in. The browser goes to the api's login route, then to the identity provider's page, then back to the api's callback. The api exchanges the one-time code over its own back channel, verifies the ID token, copies the user's teams and roles from the token, and sets a session cookie (Chapter 8).
  • Read alerts. Edge, nginx, api (cookie to user to teams, in one query), pooler, Postgres: only the caller's teams' alerts, in about 4 ms at 500 requests a second.
  • Ask the assistant. The api finds the runbook sections that match the question and loads the asker's visible alerts, removes credentials, and streams the model's answer back as Server-Sent Events with a heartbeat every 15 seconds, then the sections it cited. Stop in the browser cancels the model call.
  • The alert webhook. Alertmanager posts to the api with a bearer token, and the alert is stored in the team its label names.

One question, through the code

The core path is the one to know by heart. It touches about eight modules, and each does one thing.

routes/chat.pyrate limit; is theassistant switched on? runbooks.pysections that match:keyword and meaning queries.pythe asker's newestalerts, same query as UI redact.pycredentials removedbefore any model call only what the asker may read, enforced again by row-level security triage.pythe prompt, versioned;messages built audit.pywhat the model was given:ids and hashes, no text llm.pyany OpenAI-compatiblemodel; timeouts, errors sse.pyone JSON event per line;heartbeats every 15 s In the browserlib/sse.tsparses the stream (a POST, so not EventSource)Chat.tsxrenders tokens, citations, Stop streamed over HTTP The context is loaded before the response starts, so a database failure is still a clean JSON 503, and no connection is held while the model streams.
Figure 2.2The level-one reading path of the reference repository: the files a question passes through, in order. The first row decides what the model may see; the second turns it into a streamed answer.
In the repoapps/api/app/routes/chat.pywhat the model is given

The heart of the chat route. The comment states the rule that shapes the whole service: the model sees exactly what the asker may see, read with the same query the alert list uses.

  • Both reads take principal.team_ids(), the caller's teams from their session, never a team from the request body.
  • The question is embedded first, with no transaction open, so no pooled database connection waits on the model.
  • Everything is loaded before the response starts, which keeps errors clean and connections short.
# The model sees exactly what the asker may see: their teams' alerts,
# read with the same query as the alert list, and their teams' runbook
# sections, searched under the same rule. Loaded before the response
# starts, so a database failure is still a clean JSON 503; the session
# is closed when this function returns (DbSession's scope), so no
# connection is held while the model streams for tens of seconds.
retrieval: Retrieval | None = None
if settings.rag_context_chunks and state.llm.embedding_model is not None:
    # First: it embeds the question with no transaction open.
    retrieval = await search_runbooks(
        db,
        state.llm,
        settings,
        payload.message,
        principal.team_ids(),
        k=settings.rag_context_chunks,
    )
alerts = await newest_alerts(db, principal.team_ids(), limit=settings.chat_context_alerts)
sections = retrieval.hits if retrieval else []
messages = build_messages(payload.message, alerts, sections)

The data

teamsfrom sign-ins membershipsa role per team usersissuer + subject sessionsSHA-256 of the cookie token alertsrow-level security runbooksowned by a team runbook sectionstext + vector + model key audit eventsappend-only; ids, no text owns has split into
Figure 2.3The data model. Everything that holds team data is owned by a team, and Postgres itself filters those rows per request (Chapter 7). Users are identified by the provider and subject, never by email, because emails change and get reassigned.

A modular monolith, on purpose

The api is one process type, one deploy and one database, with visible internal boundaries: the AI logic sits behind one seam (llm.py), with the prompt in triage.py, retrieval in runbooks.py and the agent in agent.py and tools.py; identity is three files (one speaks the protocol, one turns a cookie into a user, one decides what that user may do), and the rest is the standard scaffold every service from the template shares. The web app is a separate artifact because it has a different toolchain and runtime, not because it is a microservice.

Splitting was considered and measured against. Every network call between services can hang, time out, or succeed after the caller gave up, and making a single dependency (Postgres through the pooler) fail cleanly took a whole decision record's worth of work. Latency adds up hop by hop, each service needs its own pipeline, dashboards and runbook, and data that used to join becomes an API call or a copy that drifts. Splitting pays off with several teams or a component with a very different load profile, and neither is true yet. Complexity should follow measured pain.

What would split firstWhenHow
Streaming chatChat load dominates the api's CPU (500 streams saturate 2 CPUs), or chat needs its own scaling policyThe same image as a second deployment serving only the chat route: a split at the load balancer, not in the code
A model gatewaySeveral services call models and need shared keys, quotas, cost tracking and fallbackA gateway in front of the providers; only the model's base URL changes
Alert ingestionWebhook bursts slow user requests, or ingestion must survive the api being downA queue plus a worker process from the same codebase
The frontendGlobal users, or static traffic competing with the apiThe build output on object storage behind a CDN

The pattern: the first splits are deployments of the same codebase, which buys independent scaling without network calls between your own components. And the scaling path comes in a fixed order, cheapest first: fix the query (the first real bottleneck was a missing index, p95 7 seconds down to 4 ms), give the api more CPU, add stateless replicas behind a load balancer, separate chat from reads, add database read replicas once the database is the measured bottleneck, and put bursty writes behind a queue.

Traps

SymptomCauseFix
Streaming worked in development and arrived all at once in productionThe production proxy buffered the response; development had no proxyProve changes in the production shape before calling them done.
The production image crashed on an import that worked in developmentA development-only dependency masked a missing runtime oneBuild and run the production image in CI; check every module imports.
A per-user limit allows N times the configured rateCounters kept in each worker's memoryShared state in a shared store; the api stays stateless.
The VM or the front proxy is down and no alert firesEvery check runs inside the stack that failedAn outside-in uptime check, and a dead man's switch for monitoring itself.

Questions

  1. Why does the chat route load the alerts and runbook sections before the response starts streaming?

    Answer

    So a database failure can still be answered with a clean JSON error, and so no database connection is held for the tens of seconds the model streams.

  2. What makes the api safe to scale out to many replicas?

    Answer

    It is stateless: sessions live in Postgres and rate-limit counters in Valkey, so any replica can serve any request without sticky sessions. The database connection budget sets the practical limit.

  3. When would you split the streaming chat out, and how?

    Answer

    When chat dominates the api's CPU or needs its own scaling policy. The first step is the same image deployed twice, with the load balancer sending only the chat route to the second deployment: no code split.

  4. What is the first thing to try when the service is slow?

    Answer

    Find and fix the slow query. The reference service's first bottleneck was a missing index; no amount of scaling would have fixed it.

3

Part II · Foundations, refreshed

Linux and processes

Containers did not replace Linux, they made it unavoidable: a service is still a process that holds a port, receives signals, lives inside kernel-enforced limits and leaves an exit code, and reading those correctly is most of what debugging a container means.

At a glance
  • Exit codes above 128 mean "killed by signal N": 137 is SIGKILL, almost always the out-of-memory killer; 143 is SIGTERM, a polite stop.
  • A graceful stop is SIGTERM, a grace period to finish in-flight work, then SIGKILL. The grace period must cover the longest request.
  • Memory limits are kernel cgroups; when a limit is hit, the kernel kills a process with no chance to log. With swap allowed, a limit is not a wall.
  • A process inside a container sees the host's CPU count unless it reads its own cgroup limits.
  • Most servers run a Debian or Red Hat family distribution; container images are usually a slim Debian or Alpine, and Alpine's C library breaks some compiled Python packages.
Then and now
ThenNowWhy it changed
A long-lived server you log into and inspectDisposable containers, inspected from outside through logs, metrics and exit codesThe container may already be gone when you look.
init scripts and a process supervisor on each hostThe container runtime or orchestrator restarts processesRestart policy is configuration, not a script.
"The machine has 8 cores, so 17 workers"Workers sized from the container's CPU limitA container often gets a fraction of the host.
A process grows until the machine swapsA hard memory limit per container, and a kill when it is hitOne leak kills one container, not the host.

Exit codes and signals

A process's exit code is the first clue in any container post-mortem, and the important ones are few. Codes above 128 are normally not chosen by the program: the shell, or the container runtime, reports 128 plus the number of the signal that killed it. A program can still exit with such a code itself, so it is a strong clue, not proof.

CHOSEN BY THE PROGRAM 0success 1generic failure 2shell builtin misuse 126found, not executable 127command not found 128 + N: KILLED BY SIGNAL N 129 SIGHUPthe terminal closed: anattached container dies 130 SIGINTControl-C 137 SIGKILLkill -9, or far more often theOOM killer: no log line at all 143 SIGTERMa polite stop request:finish, then exit A container that dies with 137 and an empty log almost always ran out of memory. Check the kernel's log and the OOM counters.
Figure 3.1The exit codes worth recognizing on sight. A process killed by SIGKILL cannot catch it, clean up or write a last log line, which is why out-of-memory deaths are so often mistaken for mysterious crashes.

Stopping a process gracefully

A graceful stop has three steps. The runtime sends SIGTERM; the server stops accepting new connections and finishes what it is doing; if it has not exited when the grace period ends, the runtime sends SIGKILL. docker stop waits 10 seconds by default; docker kill sends SIGKILL at once. For a service that streams model answers for up to two minutes, the default is far too short.

SIGTERM stop accepting; finish in-flight requests and streams (graceful timeout 120 s) SIGKILL grace period ends at 130 s the drain must finish before the grace period ends
Figure 3.2The order of a graceful stop. The application's own drain time must be shorter than the platform's grace period (Compose stop_grace_period, ECS stopTimeout, Kubernetes terminationGracePeriodSeconds), or the kill arrives mid-answer.

What holds a port, and which process is which

When something already listens on a port, ss -ltnp shows the listening socket and the process that owns it (an empty process column means another user owns it, so run it again with sudo). ps -p <PID> -o pid,ppid,user,lstart,cmd says who started it and when, and pstree -p <PID> shows its family. One parent with several identical children is the pre-fork worker model: one master that opened the socket, and workers that inherited it (Chapter 5).

The same tools explain the classic "port is already allocated" error from Docker: usually a forgotten stack still running. And when a network test gives a suspiciously perfect result, such as a 0.02 ms response or an instant success, the command probably ran on the wrong machine.

Limits the kernel enforces

A container is an ordinary process that the kernel isolates with two features. Namespaces give it a private view: its own process numbering, network stack and filesystem root. Control groups (cgroups) limit and account its resources: memory, CPU time, disk throughput and the number of processes. Disk space and GPUs are not cgroup limits; GPUs are handed to containers as whole devices.

Limit set, swap left at its default memory up to 50 MB then as much again in swap reached 95 MB, slowly, with no error Swap equal to memory: a hard wall memory up to 50 MB OOM killer: SIGKILL exit 137, no last log line In Compose, set memswap_limit equal to the limit. Kubernetes and ECS run containers without swap by default.
Figure 3.3Measured on the reference stack: a memory limit with swap available is not a limit. The default allows as much again in swap, so a leak turns into slowness instead of a clear failure.

Three more silent failures of memory limits are worth knowing. An out-of-memory kill of one worker inside a container leaves the container "healthy": only a log line from the parent, a cgroup counter and an alert record it. Docker's own OOMKilled flag can read false even after the main process was killed; the kernel's log (journalctl -k) is the authority. And lowering a live container's limit kills processes even when swap is allowed.

In the repoapps/api/gunicorn.conf.pycounting the CPUs a container may really use

The server sizes its worker pool from the CPUs it may use. Python's os.cpu_count() reports the host's cores even inside a limited container, so the configuration reads the cgroup's CPU quota directly.

  • /sys/fs/cgroup/cpu.max holds "quota period", or "max period" when unlimited; quota divided by period, rounded up, is the usable CPU count.
  • The same file is written by a Compose cpus: limit or a Kubernetes CPU limit, so one image sizes itself on both. ECS is the exception, although the comment names it: a container's cpu there is only a relative share, and the task's hard limit applies to the task as a whole, so the container's own file may read "max". The reference ECS stack therefore sets WEB_CONCURRENCY explicitly.
  • Pinning to specific cores is covered by the CPU affinity call.
def available_cpus(cpu_max: Path = Path("/sys/fs/cgroup/cpu.max")) -> int:
    """CPUs this container may actually use.

    os.cpu_count() reports the host's cores even inside a CPU-limited
    container. A `--cpus` / compose `cpus:` / Kubernetes CPU limit is the
    cgroup v2 quota in cpu.max: "<quota> <period>", or "max <period>" when
    unlimited. sched_getaffinity() covers `--cpuset-cpus`. An ECS
    container's `cpu` is a relative share, not a quota: set WEB_CONCURRENCY
    there (infra/aws/service.py does).
    """
    try:
        quota, period = cpu_max.read_text().split()
        if quota != "max":
            return max(1, -(-int(quota) // int(period)))  # ceiling division
    except (OSError, ValueError):
        pass
    return len(os.sched_getaffinity(0))

How many containers fit on one host?

There is no fixed number. An idle container costs a few megabytes; the limit is the sum of what the containers actually use: memory (the working set, not the idle size), CPU, process counts, open files, disk and network connection tracking. Measure with docker stats, add up the working sets, add 20 to 30 percent headroom and the host's own needs, and give each container a memory limit so that one leak kills only itself. Memory is the resource that kills; the others mostly degrade: CPU starvation, process or file-descriptor exhaustion, a full disk from image layers and logs.

Distributions, and the images built on them

FamilyMembersPackagesWhere you meet it
DebianDebian, Ubuntuapt, .debMost servers (Ubuntu LTS); the -slim container images
Red HatRHEL, Rocky, Alma, Fedora, Amazon Linuxdnf, .rpmEnterprises; Amazon Linux on AWS
AlpineAlpineapkTiny container images, built on the musl C library and busybox

A distribution is the kernel plus the userland, a package manager and defaults. For Python services, the choice of base image matters more than it looks: wheels built for glibc (tagged manylinux) do not run on Alpine's musl library. Many popular packages now also publish musllinux wheels, but any package that does not is compiled from source, or fails to install. A slim Debian image is the safer default.

Debugging at the kernel boundary

  • The kernel's own log, journalctl -k or dmesg, is the authority on who the out-of-memory killer killed and why.
  • strace stops a process at every system call: extremely informative and extremely slow, for a single reproduced problem.
  • Slim images have no ps: docker top from the host lists a container's processes.
  • A sidecar debug container started in the target's network namespace sees exactly what the application sees, with tools the application image does not carry.

Traps

SymptomCauseFix
A container dies with exit 137 and no error in its logThe OOM killer sent SIGKILLRead the kernel log and the cgroup's OOM counter; raise the limit or fix the leak.
Memory grows past the limit without any killSwap is allowed: the default is as much againSet the swap limit equal to the memory limit.
A stopped-on-purpose container stays down after a "crash test"docker kill counts as a manual stop, so the restart policy ignores itSimulate crashes with SIGKILL from the host's process namespace.
An unhealthy container is never restartedDocker's restart policies act on exit only; "unhealthy" matters to dependencies, and orchestrators run their own checksDefine the check where the orchestrator reads it (Kubernetes probes, an ECS task-definition check), or make the process exit.
A worker pool is far too large inside a containeros.cpu_count() reports the host's coresRead the cgroup's CPU quota.
A wait loop never ends, or a kill loop kills itselfpgrep -f or pkill -f matches the shell running itTrack and kill by process ID.
A script carries on after a failed command! cmd is exempt from set -eTest the condition explicitly with if.
A rename put a directory inside anothermv src existing-dir moves into the directoryCheck the target does not exist, or use mv -T.
A password was pasted into a chat to get past sudosudo needs a terminal for its promptThat password is burned: rotate it.

Questions

  1. A container exits with 137 and nothing in its log. What happened, most likely, and where do you confirm it?

    Answer

    It was killed by SIGKILL, almost always the kernel's out-of-memory killer enforcing the container's memory limit. Confirm it in the kernel log and the cgroup's OOM counter; Docker's own flag is not always reliable.

  2. Why must the application's drain time be shorter than the platform's stop grace period?

    Answer

    Because when the grace period ends, the platform sends SIGKILL. If the application is still finishing a long stream, that stream is cut off mid-answer.

  3. Why can a 50 MB memory limit let a container use 95 MB?

    Answer

    With swap available and no swap limit, Docker allows as much again in swap. The container slows down instead of being killed. Setting swap equal to memory makes the limit a wall.

  4. Why might a Python service fail to install its dependencies on an Alpine base image?

    Answer

    Alpine uses the musl C library, and wheels built for glibc (manylinux) do not run on it. A package without a musllinux wheel falls back to compiling from source, which needs compilers and headers, or fails.

4

Part II · Foundations, refreshed

Networks: DNS, TCP, TLS and HTTP

Every request is a stack of protocols wrapped inside each other, HTTP inside TLS inside TCP inside IP, and knowing what each layer does, and how each one fails, turns "it can't connect" from a mystery into a short checklist.

At a glance
  • A request goes DNS (find the address), TCP (a reliable byte stream), TLS (encryption and identity), then HTTP (the request itself).
  • TLS does three jobs: confidentiality, integrity and authentication. Certificates prove identity; a key agreed per session encrypts the data, so a stolen server key cannot decrypt recorded traffic.
  • "Connection refused" means the path works and nothing listens; a timeout means packets vanish. They point to different places.
  • A server bound to 127.0.0.1 accepts only local connections; inside a container that means not even through a published port.
  • HTTP/2 multiplexes streams over one connection; HTTP/3 moves to QUIC over UDP to stop one lost packet from stalling every stream.
Then and now
ThenNowWhy it changed
HTTPS only on login pagesHTTPS everywhere, with free automated certificatesCertificates became free and automatic; browsers mark HTTP as unsafe.
RSA key transportEphemeral Diffie-Hellman in TLS 1.3, giving forward secrecyA stolen server key must not decrypt years of recorded traffic.
HTTP/1.1, six connections per siteHTTP/2 and HTTP/3 at the edge; HTTP/1.1 behind itMultiplexing and faster handshakes for clients; simplicity inside.
A VPN to reach internal machines; ports opened for public servicesMesh networks for private access; outbound tunnels for public exposureNothing listens on the open internet, and each device has a stable private address.

A request, layer by layer

IPaddresses and routing, hop by hop TCPa reliable, ordered byte stream; the port picks the program TLSencryption, integrity, the server's identity HTTPPOST /api/chat/streamthe request: method, path, headers, body Which layer failed? IP: no route, wrong address TCP: refused (nothing listens) or timeout (packets dropped) TLS: certificate name, chain, expiry, or protocol mismatch HTTP: a status code from the server or a proxy
Figure 4.1Each layer wraps the one above. Terminating TLS means decrypting it there, usually at a proxy or load balancer, with plain HTTP behind it on a private network.
Client DNS resolver Server DNS where is example.com? root, top-level domain, authoritative an address, cached for its TTL TCP SYN SYN-ACK ACK TLS ClientHello: ciphers, a key share, and the hostname in plain text (SNI) ServerHello: the server's key share, also in plain text both now derive the same keys (ephemeral Diffie-Hellman); no key is ever sent encrypted: certificate for that name, signature over the handshake, Finished client checks: chain to a trusted root, name, expiry client Finished, encrypted HTTP request, encrypted with the session key response
Figure 4.2The first request to a site, in order, with TLS 1.3. A raw IP address skips DNS; a kept-alive connection skips TCP and TLS for later requests. The key shares ride in the first two messages, so the handshake takes one round trip and the certificate already travels encrypted. SNI is why one address can serve many certificates, and it is sent before anything is encrypted.

What TLS actually guarantees

Encryption solves three separate problems: confidentiality (nobody can read it), integrity (nobody can change it unnoticed) and authentication (the server is who it claims). TLS combines four tools to get all three.

ToolWhat it doesIts role in TLS
Symmetric encryptionOne shared key encrypts and decrypts; fastCarries all the data, with the session key
Asymmetric key pairsA public key anyone may have, a private key only the owner has; slowThe server signs the handshake to prove it holds the private key
Key agreement (Diffie-Hellman)Two strangers derive the same secret over an open channelProduces the session key without ever sending it
CertificatesA public key and a domain name, signed by a certificate authority whose root ships with the operating system or browserAnswer "whose public key is this?"
Alice Bob On the open network public color public color + her secret + his secret both mixtures travelin the open her secret + his mixture= the shared key his secret + her mixture= the same key an eavesdropper sees both mixtures but cannot unmix them
Figure 4.3The paint analogy for Diffie-Hellman. The key is never sent: each side computes it. Because the secrets are new for every session, recording traffic and later stealing the server's private key decrypts nothing: that is forward secrecy, which the older RSA key transport lacked.

Why a certificate cannot decrypt anything: it holds only the public key. The private key never leaves the server, and the session key does the encrypting. A machine in the middle cannot sign the handshake as example.com without that private key, so the client's checks fail. When both sides present certificates, the connection is mutual TLS, common between services.

Three related distinctions come up constantly. In transit means TLS, SSH or a VPN; at rest means disks, databases and object storage, usually with envelope encryption: a data key encrypts the data, and a master key in a key management service encrypts the data key. Encryption is reversible with a key; hashing is one-way (passwords are stored with a slow, salted password hash such as Argon2id or bcrypt, never encrypted); encoding, such as base64, is formatting, not secrecy.

HTTP: versions and status codes

HTTP/1.1 sends one request at a time per connection, so browsers open about six. HTTP/2 is binary and multiplexes many streams over one TCP connection, with header compression, but one lost packet stalls every stream on it (TCP's head-of-line blocking). HTTP/3 runs over QUIC, built on UDP: loss affects only its own stream, TLS 1.3 is built in, handshakes are faster, and connections survive a change of network. The common setup is HTTP/2 or 3 between clients and the edge, and HTTP/1.1 behind it. gRPC needs HTTP/2.

StatusMeaning, and what it usually tells you
200, 201, 202, 204OK; created; accepted for later processing (a webhook receiver or a queued job); no content
301, 302, 304Moved permanently; temporarily; not modified, so the cached copy is still valid
400, 422Malformed request; a validation failure (FastAPI's choice, not 400)
401, 403, 404Not authenticated ("who are you?"); authenticated but not allowed; not found. Return 404, not 403, for things a caller may not even know exist
405, 409, 429The path exists but not with that method; a conflict; rate limited
500An unhandled exception in the application
502The proxy got no valid response: the backend is down or crashed
503Deliberately unavailable: overloaded, draining, or a dependency is down
504The proxy timed out: connecting (the backend is unreachable and packets vanish) or waiting for the reply (the backend is too slow). The proxy's log says which

"I can't reach it": a method

1 The app itselflistening, on the rightinterface? curl from thebox proves the app only 2 The firewallhost rules, securitygroups, published ports 3 From the clientthe same test from wherethe user actually is 4 A control testa service known to work,along the same path Connection refuseda packet arrived and was rejected: the pathworks and nothing listens there. Look inside. Timeoutthe packet vanished: a firewall, a frozenprocess, a wrong route. Look along the path.
Figure 4.4Test from the source outward, and note where each command ran. A suspiciously perfect result, such as a 0.02 ms reply, usually means the test ran on the wrong machine.

The most common single cause is the bind address. 127.0.0.1 (the loopback interface) never leaves the machine, so a server bound there accepts only local connections; 0.0.0.0 listens on every interface. Inside a container, a server bound to loopback is unreachable even through a published port, because the published port arrives on the container's own network interface.

The opposite mistake is just as common in development: publishing a port on all interfaces. Docker's published ports are inserted into the host's packet filtering before the host firewall's rules, so a port published on 0.0.0.0 is reachable from the network even when the host firewall says otherwise.

In the repocompose.override.yamldevelopment ports bound to loopback

The development overlay publishes the api's port only on the host's loopback address by default, and says why in its first lines.

  • A developer's stack is not reachable from the office network unless they choose so with BIND_ADDR.
  • The reason given is the firewall trap: published ports bypass host firewalls such as ufw.
  • In the production shape only the HTTPS edge is published at all; the api, database and cache have no host ports.
# Development overrides, merged automatically by plain `docker compose ...`.
# Source is bind-mounted for hot reload. Ports bind to 127.0.0.1 unless
# BIND_ADDR says otherwise, so a dev stack is not reachable from the network
# (published ports bypass host firewalls such as ufw).
services:
  api:
    build:
      target: dev
      # Your UID and GID for the image's `dev` user (make exports them).
      args:
        DEV_UID: ${DEV_UID:-1000}
        DEV_GID: ${DEV_GID:-1000}
    ports:
      - "${BIND_ADDR:-127.0.0.1}:${API_PORT:-8010}:8010"

Private access without open ports

Two tools changed how small teams reach machines. Tailscale builds a private WireGuard mesh: every device gets a stable private address, and nothing is exposed publicly. Cloudflare Tunnel runs a small agent on the machine that opens an outbound connection to Cloudflare, which maps public hostnames to local ports, so the machine never listens on the internet at all.

Below HTTP, two transports remain. TCP is reliable and ordered, with a handshake, retransmission and congestion control. UDP is fire-and-forget datagrams with none of that: low overhead for DNS, video, games and QUIC.

Traps

SymptomCauseFix
The service works on the box but nobody else can reach itIt is bound to 127.0.0.1Bind to 0.0.0.0 inside the container, and publish deliberately.
A port the firewall blocks is reachable from the networkDocker's published ports bypass the host firewallPublish on loopback, or filter in Docker's own chain.
A client reports a name-resolution error, not "refused", when a service is downA stopped container disappears from Docker's internal DNSTreat resolution errors as "unavailable" and answer 503.
A database came back but clients failed for another 15 secondsThe connection pooler cached the failed DNS answerLower the negative DNS cache time.
Sequential test clients look like one clientDocker reuses the lowest free IP addressRun clients concurrently, or identify them some other way.
Every local port suddenly insists on HTTPSAn HSTS header was sent for localhostDisable HSTS locally; set a long max-age only for a real domain.

Questions

  1. What is the difference between "connection refused" and a timeout, and where does each point you?

    Answer

    Refused means a packet reached the machine and was rejected, so the path works and nothing listens on that port: look inside the host or container. A timeout means packets vanished: look along the path, at firewalls, routes, or a frozen process.

  2. Why can a public certificate not be used to decrypt traffic?

    Answer

    It contains only the public key. The data is encrypted with a session key agreed per connection, and proving identity needs the private key, which never leaves the server.

  3. What is forward secrecy, and what provides it?

    Answer

    The property that a later theft of the server's private key cannot decrypt traffic recorded earlier. It comes from ephemeral Diffie-Hellman key agreement: a new secret for every session, never stored.

  4. A proxy returns 502 and later 504. What does each suggest about the backend?

    Answer

    502: the proxy got no valid response, so the backend is down or crashed. 504: the proxy timed out, either connecting (the backend is unreachable) or waiting for the reply (the backend is up but too slow); the proxy's error log says which.

5

Part II · Foundations, refreshed

Concurrency and the Python backend

A modern Python service is an async application on an event loop, run by a server that forks one worker per CPU, with every dependency locked by hash, and its performance depends far more on never blocking that loop than on any choice of framework.

At a glance
  • Four different things: an HTTP client (requests, httpx), a web framework (FastAPI), the toolkit under it (Starlette), and the ASGI server that runs it (uvicorn).
  • One async worker holds thousands of waiting connections on one thread. Add workers for CPU, about one per core, not for waiting.
  • One synchronous call inside async code stalls every request on that worker. The reference service measured a health check taking 4.8 seconds because of one.
  • Workers share one listening socket inherited from a master process; each has its own memory, so shared state goes to a shared store.
  • pyproject.toml lists what you chose; uv.lock pins everything with hashes; images install exactly the lock.
Then and now
ThenNowWhy it changed
WSGI frameworks (Flask, classic Django), one request per threadASGI frameworks (FastAPI, Starlette, Django 3+) on an event loopServices now wait on databases, models and other services far more than they compute.
pip freeze into a flat requirements fileA manifest of direct dependencies plus a lockfile of the whole graph with hashes (uv, Poetry)Reviewable choices, and byte-identical installs.
"2 × cores + 1" workersOne async worker per usable CPUThe old rule was for synchronous workers that block while waiting.
Hand-written validation and settings parsingTyped models (Pydantic) for requests, responses and settingsInvalid input and invalid configuration fail early and clearly.
Virtual environments activated on serversThe virtual environment lives inside the image, used by pathThe image is the environment; activation is just a shell convenience.

Four things that are easy to confuse

gunicornprocess manager: a master and N workers, restarts, drains uvicornthe ASGI server: the socket and the event loop THE ASGI CONTRACT: async def app(scope, receive, send) FastAPIyour routes, validation, dependencies StarletteRequest, Response, middleware, streaming httpx / requestsa client, inside your code, calling others You give the server your app, not the other way round. Any ASGI server runs any ASGI framework. WSGI is the older, synchronous contract; the two do not mix. uvicorn alone is production-ready now; gunicorn adds one process manager across WSGI and ASGI services, and a long track record.
Figure 5.1The stack of a Python web service. The reference api runs FastAPI under uvicorn workers managed by gunicorn in production, and plain uvicorn with auto-reload in development.

How the event loop actually waits

A suspended coroutine is not a paused thread. Calling an async def function runs nothing; it returns a coroutine object. The event loop runs one coroutine at a time on one thread, and when a coroutine awaits I/O, the loop parks it and blocks in a single kernel call, epoll_wait(), until any registered socket is ready or the nearest timer is due. Three concurrent two-second sleeps make one two-second wait, and finish in two seconds, not six.

Every wait is an await the loop runs other requests while one waits request 1request 2request 3 all three done in about one wait; the waiting costs no CPU One synchronous call the loop is stuck until it returns blocking call: nothing else runs requests 2, 3 and /health wait measured: /health took 4.8 s; the linter's async rules did not flag the SDK's synchronous client
Figure 5.2The event loop's contract: never block it. Async does not bypass Python's global interpreter lock; it is one thread that never wastes time waiting. A blocking call breaks the contract for every request on that worker at once.

Two consequences show up in production. On a busy loop, wall-clock timeouts expire although the dependency answered in time: the reply waits in the loop's queue while the budget runs out. The reference service saw its rate limiter failing open and pool timeouts firing at about 46% CPU. So CPU percentage is a poor saturation signal for async Python; event-loop lag, how late a timer fires, measures it directly, and the service exports it as a metric. And cancellation is normal: a user closing the tab cancels the stream, and finally blocks must close the model connection, which stops the billing.

Workers: processes for CPU

One async process already holds thousands of waiting connections, so extra workers are for CPU: roughly one per core. They are processes, not threads, because each process has its own interpreter lock, so N workers really use N cores.

Master process bind and listen once, then fork N workers one socket:8010 Worker 1event loop, own memory,own pool: 20 connections Worker 2event loop, own memory,own pool: 20 connections Worker None per usable CPUread from the cgroup accept State is per worker a dict cache differs in each; counters too. Shared state goes to a shared store Connections multiply workers × pool × replicas must stay under the database's limit
Figure 5.3The pre-fork model. ss -ltnp shows one listening socket with N process IDs. The kernel distributes connections; the costs are per-worker memory and per-worker connection pools.
In the repoapps/api/gunicorn.conf.pyworker count, heartbeat, drain, keep-alive

Four production settings, each with the reason it has the value it has. Every one can be overridden from the environment, so the same image fits a laptop, a VM or a one-process-per-task platform.

  • Workers: one per usable CPU. On ECS or Kubernetes, one worker per task and more replicas.
  • The timeout is a heartbeat, not a request deadline: a worker is killed only when its loop is blocked that long, so long streams are unaffected.
  • The graceful timeout must cover the longest stream and stay below the platform's stop grace period (Chapter 3).
  • Idle keep-alive must outlive the proxy's, or the proxy reuses a connection the worker just closed: sporadic 502s.
# Async workers: one per usable CPU. Each worker's event loop already
# serves many concurrent connections; the classic "2 x cores + 1" rule is
# for sync workers that block while waiting on I/O. On ECS/Kubernetes set
# WEB_CONCURRENCY=1 and scale with replicas instead.
workers = int(os.environ.get("WEB_CONCURRENCY") or available_cpus())
worker_class = "uvicorn_worker.UvicornWorker"

# A heartbeat, not a request deadline: an async worker is killed only when
# its event loop is blocked this long (a stuck synchronous call). Long SSE
# streams are unaffected.
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))

# On SIGTERM, in-flight requests get this long to finish. It must cover the
# longest LLM stream (LLM_STREAM_TIMEOUT_S) and stay below the container
# stop grace period (compose stop_grace_period, ECS stopTimeout, k8s
# terminationGracePeriodSeconds), which otherwise SIGKILLs first.
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "120"))

# Idle keep-alive must outlive the proxy's upstream keep-alive (nginx
# keepalive_timeout 60s; an AWS ALB idle timeout likewise). Otherwise the
# proxy reuses a connection the worker has just closed: sporadic 502s.
keepalive = int(os.environ.get("GUNICORN_KEEPALIVE", "75"))

Dependencies and environments

A virtual environment is just a directory: bin/python is a link to the real interpreter, and only third-party packages are installed in it. Activating it merely puts its bin first on the shell's path, which is why venv/bin/python app.py works without activation, and why services and images run it by path. Modern Linux distributions refuse a global pip install because their own tools are Python programs pinned to specific package versions.

pyproject.tomldirect dependencies:what you chose uv.lockthe whole graph, withhashes; committed uv sync --frozeninstalls exactly the lock;never checks it is current The imagewhat was reviewed,byte for byte uv lock THREE TIERS OF PYTHON DEPENDENCY Pure Pythonruns anywhere Wraps a C librarywheels per OS and processor Calls a separate programffmpeg, pandoc: OS packages
Figure 5.4A manifest, a lock, a frozen install. The lockfile is the fix for pip freeze's flat list of everything: you review the choices in the manifest, and the lock makes every install identical. The tiers below explain why something that works on a laptop can fail in an image.

Compiled wheels are built per operating system and per processor: an Apple Silicon laptop builds arm64 images unless told otherwise, GPU builds of some libraries exist only for Linux on NVIDIA, and browser-automation tools need browser binaries and a dozen system libraries. The structural fix is to put system dependencies in the Dockerfile and develop inside the image that ships. The reliable check is to build and run the image.

In the repoapps/api/Dockerfilethe dependency layer

The dependency layer of the api image. Only the two manifests are mounted into this step, so it is rebuilt only when dependencies change, not on every code edit.

  • --frozen installs exactly what uv.lock pins and never re-resolves. It does not check the lock against pyproject.toml, so a dependency added to the manifest without re-locking is silently missing. uv sync --locked or uv lock --check fails on a stale lock. The reference CI runs --locked, so a stale lock fails the pull request, and the images install the checked lock with --frozen.
  • --no-dev keeps test and lint tools out of production.
  • A cache mount keeps downloaded wheels between builds without baking them into a layer.
# Only the manifests are mounted, so this layer is rebuilt only when
# dependencies change. The cache mount keeps downloaded wheels between
# builds without baking them into a layer.
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    uv sync --frozen --no-dev --no-install-project

Traps

SymptomCauseFix
Every request on a worker slows down at once, health checks includedA synchronous call inside async code blocks the event loopUse async clients, or move blocking work to a thread; watch event-loop lag.
Timeouts and fail-opens fire at moderate CPUOn a busy loop, wall-clock timers fire lateTreat loop lag as the saturation signal; scale before it rises.
A probe with a 2-second timeout took over 10 secondsasyncio.timeout() cancels once, and the cleanup after it can block againRun the check as a task, wait with a deadline, and abandon it rather than cancel.
A canceled stream kept the model call running and billingThe response never closed the async generatorClose generators explicitly (contextlib.aclosing) and stop the upstream call in finally.
A request-scoped database session held its connection for a whole streamed answerFastAPI's yield dependencies close after a streamed response has been sentScope the session to the function, and load data before streaming.
The request id is missing from error responsesStarlette's exception handler runs outside your middlewareHandle errors inside the middleware that sets the id.
A runtime check vanished in productionassert is removed under python -OUse an explicit check and raise.
A worker hangs until it is killedA forgotten breakpoint() waits for keyboard inputSet PYTHONBREAKPOINT=0 in production images.
Bursts of 502 under loadWorker recycling after N requests closed keep-alive connections the proxy was reusingLeave recycling off unless memory growth is measured.
The production image crashed on import httpxIt was only a development dependency; the SDK depends on a differently named packageRun the production image in CI and import every module.
A hung dependency stalls every synchronous endpointThe thread pool for sync code has 40 slots, and they all waitBound every wait; prefer async endpoints.

Questions

  1. Why do three concurrent two-second sleeps on an event loop finish in two seconds?

    Answer

    Each await parks its coroutine, and the loop blocks once in the kernel until the nearest timer is due. All three timers expire together, so the waits overlap instead of adding up.

  2. How many async workers should a service run, and why not more?

    Answer

    About one per usable CPU, read from the container's limit. Each worker already handles thousands of waiting connections; extra workers only add memory and database connections without adding CPU.

  3. Why is CPU percentage a poor saturation signal for async Python?

    Answer

    Work queues on the event loop long before CPU reaches 100%, and timers start firing late. Event-loop lag measures that queueing directly.

  4. What does uv.lock give you that pyproject.toml does not?

    Answer

    The full resolved dependency graph with exact versions and hashes. The manifest lists only the direct dependencies you chose; the lock makes every install identical.

6

Part III · Data

Postgres: schema changes and indexes

Schema changes are versioned migrations that every database applies in the same order, written so the running release keeps working while they run, and indexes are chosen from slow queries measured under realistic load, because a query plan on an idle database tells you very little.

At a glance
  • A migration is a script that changes the schema. Each database records which ones it has run and applies only the missing ones, in order, so every database converges on the same schema.
  • Generating a migration and applying it are separate steps. Read what the generator wrote: it turns renames into a drop plus an add, and never writes data migrations.
  • On a live table: a lock timeout on every migration, concurrent index builds, foreign keys added unvalidated and validated later.
  • An index is a separate ordered structure that costs disk, memory and write speed. Add one for a measured slow query, not just in case.
  • Transactional databases use indexes to jump to a few rows; analytical warehouses use their storage layout to skip past almost all of them.
Then and now
ThenNowWhy it changed
A DBA runs SQL scripts by hand in each environmentMigrations in the repository, applied by the pipeline to every databaseEvery environment converges on the same schema, recorded and reviewable.
Numbered migration files that collide across branchesRandom revision IDs linked to their parentTwo branches can each add a migration without a numbering clash.
The application creates its tables at startupThe migration history is the source of truth; nothing runs at startupStartup cannot alter a schema behind the team's back.
Schema changes in a maintenance windowOnline changes: expand and contract, lock timeouts, concurrent buildsThe old release keeps serving while a migration runs.
Indexes added in case they helpIndexes chosen from measured slow queries, under load, with their write cost weighedEvery index slows every write and takes memory.

Migrations: a history every database replays

Code and database must agree, and there are many databases: each developer's, CI's throwaway one, staging, production. A migration framework (Alembic for SQLAlchemy here) keeps them aligned. Each migration file has a random identifier and names its parent, so the history is a linked list, not a numbered sequence. Each database stores the revision it is at, and an upgrade runs only the files after it.

39b0de02create alerts 75397b87external id 51ef9f54index by time 3713e568teams and users ce83ff21row-level security 9c9fd881runbooks each file names its parent (down_revision); the dates in file names are labels for humans production records 3713e568: two behind an upgrade runs only the two it is missing, in order CI starts empty and replays them all "heads" must print exactly one
Figure 6.1The first six of the reference schema's nine revisions. Random identifiers never collide when two branches each add a migration; if two branches do, the tool reports two "heads", and the team merges them.
1 Edit the modela declaration only;nothing changes yet 2 Autogeneratediffs models against adatabase; writes a file 3 Read and fix itrenames, data, locks,concurrent indexes 4 Upgraderuns the DDL once;records the revision 5 CI checkmodels andhistory agree The generator guesses from a diff: a rename looks like a drop plus an add, and the data is lost; data migrations are never generated; an index comes out as a blocking CREATE INDEX.
Figure 6.2Generating and applying are separate steps. The application never runs migrations at startup and never builds its schema from the models: the migration history is the source of truth, and CI fails if the two disagree.

Changing a table that is in use

During a deploy the old release keeps serving while migrations run, so a migration must not break it and must not block it. Three techniques do most of the work, and the reference repository has a migration that uses each.

Without a lock timeout a long query holds a lock on the table ALTER TABLE waits for an exclusive lock every new query queues behind the ALTER users get 503 after 10 s With lock_timeout = 5s on every migration the long query holds its lock ALTER gives up at 5 s new queries are never blocked; retry the migration off-peak
Figure 6.3Why a migration needs a lock timeout. A schema change waiting for a lock makes every later query on the table wait behind it. Measured on the reference stack: users got 503 errors after 10 seconds.
  • Add, don't change. A nullable column, or one with a constant default, is instant (metadata only). A volatile default, such as gen_random_uuid() or clock_timestamp(), rewrites the whole table under an exclusive lock. Renames and type changes become expand and contract across releases (Handbook 3, Chapter 13).
  • Build indexes concurrently. A plain CREATE INDEX blocks writes for the whole build. CONCURRENTLY does not, but cannot run inside a transaction, and if it fails midway it leaves an invalid index to drop before retrying.
  • Add foreign keys in two steps. NOT VALID is instant and applies to new rows; VALIDATE CONSTRAINT in its own transaction then checks existing rows without blocking writes. The reference teams migration used all three patterns on 2 million rows in 1.5 seconds under load, with no failed request.
  • Keep the lock timeout for the whole run. Alembic wraps a run in one transaction, and an autocommit block commits it. A SET LOCAL ends with that commit: the reference once set its timeout that way, and measured, it read 5 s at the start of a run and 0 inside the first block, after it, and in every later migration, so a run past the teams migration enabled row-level security on alerts, which takes an exclusive lock, with no timeout. It now uses a plain SET on the run's own connection, which lasts for the session: 5 s at every step, CREATE INDEX CONCURRENTLY included (one that gives up leaves an invalid index, to drop before retrying). Every test run now checks it: probe migrations through the real migration setup, failing if the timeout is ever not 5 s.
  • Name every constraint, or the tool cannot write a downgrade that drops it.
  • Migrations run as the schema owner, directly against Postgres. The application's own role can read and write rows but cannot change the schema.
  • One run at a time. Where every copy of the service migrates as it starts (an init container per task), two can start together, and two runs at once collided: one failed, three times in three. The fix is an advisory lock, and the first two versions failed in instructive ways. A transaction lock is released when a CONCURRENTLY migration commits midway. A blocking session lock makes the waiting run hold a snapshot that CREATE INDEX CONCURRENTLY waits for, and Postgres reports a deadlock. What works is a session lock on a separate connection, polled, so the waiting run holds nothing.
In the repomigrations/versions/…_add_alert_external_id.pythe pattern to copy

A column and a unique index added to a table in use, without blocking writes. The docstring explains the pattern so the next migration can copy it.

  • The column is nullable, so adding it touches only metadata.
  • The index is built CONCURRENTLY, inside an autocommit block because it cannot run in a transaction.
  • The downgrade reverses both, also concurrently.
"""add alert external id

Zero-downtime shape, the pattern for any index on a table in use:
- ADD COLUMN ... NULL is instant (metadata only, no table rewrite).
- A plain CREATE INDEX blocks every INSERT/UPDATE/DELETE until it
  finishes; CONCURRENTLY builds without blocking writes, but cannot run
  inside a transaction - hence the autocommit block. If it fails midway
  it leaves an INVALID index that must be dropped before retrying.
def upgrade() -> None:
    op.add_column("alerts", sa.Column("external_id", sa.Text(), nullable=True))
    with op.get_context().autocommit_block():
        op.create_index(
            "uq_alerts_external_id",
            "alerts",
            ["external_id"],
            unique=True,
            postgresql_concurrently=True,
        )

Indexes

An index is a separate, ordered structure that lets the database find rows without reading the whole table. It costs disk and memory and slows every insert and update, so the right number is the smallest set that meets a measured need. EXPLAIN shows whether a query uses one.

Index typeFor
B-tree, the defaultEquality, ranges and sorting: almost every index
Composite (a, b)Column order matters: it serves a and a, b well; for b alone, only through a full index scan or, from Postgres 18, a skip scan when a has few values
UniqueA B-tree that also forbids duplicates
PartialOnly rows matching a WHERE: small, such as open alerts only
Covering (INCLUDE)Extra columns stored in the index, so a query can often skip the table: an index-only scan still visits every table page the visibility map does not mark all-visible
ExpressionA computed value, such as lower(email)
GINMany values per row: arrays, JSONB, full-text search
GiST, SP-GiSTGeometry, ranges and other special types
BRINTiny, a minimum and maximum per block range; only for data stored in order, such as append-only time series
HNSW, IVFFlat (pgvector)Approximate nearest-neighbor vector search (Handbook 1, Chapter 4)

The first bottleneck was a missing index

Under a load test of only 40 requests a second, the dashboard's two alert queries reached a 95th-percentile latency of 7 seconds, and 45% of requests failed: every database connection was busy with a slow query, and the connection pool timed out. The query statistics showed two queries averaging over a second and a half; their plans were parallel sequential scans of 2 million rows, reading about 235 MB each. On an idle database the same plans said 43 ms and 152 ms, which looks harmless. Forty copies at once, each scanning the whole table, is a different workload.

IndexNewest firstBy severitySizeInsert cost, 200k rows
none152 ms43 msnone244 ms
(created_at, id), shipped0.125 ms3.8 ms60 MB369 ms
plus (severity, created_at, id)0.125 ms0.063 ms+78 MB726 ms

One index shipped. It met the target fifty times over, and the second would have doubled the write cost for a query that was already fast. Its trigger was written down instead: add it when the severity query shows up in the slow-request alert. After the fix, under the same load: a 95th percentile of 4.07 ms and no failures.

The planner can choose badly as the data grows

Reading the newest alerts across a user's teams looks like a simple WHERE team_id = ANY(...). Measured on 2.5 million rows, the planner sometimes walked the global time index and filtered, which cost 13 ms when one of the user's teams was large but quiet (103,036 rows read and discarded), and the bad case grows with the table. Reading each team through its own index and merging the results bounds the cost, whatever the data: 0.05 to 0.1 ms in every case.

In the repoapps/api/app/queries.pya bounded read, with its measurement

The shared query behind both the alert list and the assistant's context. Its docstring records why it is written as a LATERAL join, with the numbers.

  • One shared query means the list and the assistant apply exactly the same visibility rule.
  • Paging uses a keyset cursor (the last row's time and id), not an offset, so deep pages stay fast.
async def newest_alerts(
    db: AsyncSession,
    team_ids: Sequence[int] | None,
    *,
    limit: int,
    severity: Severity | None = None,
    before: tuple[datetime, int] | None = None,
) -> list[AlertOut]:
    """Newest first among `team_ids` (None: every team), optionally only one
    severity, optionally only older than a keyset cursor.

    Several teams are read one team at a time and merged - a LATERAL join,
    each team through the (team_id, created_at, id) index - so the cost is
    bounded by teams x limit index entries whatever the data looks like.
    The obvious `WHERE team_id = ANY(...)` lets the planner walk the global
    time index and filter instead, which depends on the data: measured on
    2.5M rows, 0.015ms to 13ms (103k rows filtered out when one of the
    user's teams is large but quiet), against 0.05-0.1ms here in every case.
    """

Analytical databases do it differently

Warehouses such as Snowflake, BigQuery and ClickHouse scan huge tables and mostly do without B-trees. Their main "index" is columnar storage: each column stored and compressed separately, so a query reads only the columns it names. Zone maps keep a minimum and maximum per block so blocks that cannot match are skipped; partitioning or clustering by a date or tenant skips whole files; sort keys make range filters and joins cheap; bitmap indexes and Bloom filters help with low-cardinality columns and "definitely not here" checks; materialized views trade storage for precomputed results. A transactional index jumps to a few rows; an analytical layout skips past almost all of them.

Traps

SymptomCauseFix
A renamed column lost its dataAutogenerate wrote the rename as a drop plus an addRead every generated migration; write renames explicitly, or as expand and contract.
A migration logged success but changed nothingA SET before the migration's transaction began opened a transaction that was then rolled backRun the SET inside the migration's transaction. Make it a session SET, not SET LOCAL, if it must outlast an autocommit block.
Writes stall during a deployA plain CREATE INDEX, or a validated foreign key added in one stepBuild indexes concurrently; add foreign keys NOT VALID, then validate.
Every query on a table waits during a migrationA schema change queued for a lock, and later queries queued behind itA lock timeout on every migration; retry off-peak.
A query that was fast in testing is slow in productionAn idle EXPLAIN on small dataMeasure under realistic load with production-sized data.
A copied table lost its auto-incrementLIKE … INCLUDING DEFAULTS does not copy identity columnsAdd INCLUDING IDENTITY.
A command defaults to a database named after the userlibpq uses the user name when no database is givenAlways pass the database name explicitly.
An upgraded Postgres container refuses its data directoryA major version cannot read an older version's filesDump and restore, or pg_upgrade; never just change the image tag.
Monitoring itself used a quarter of the database's timeThe exporter's statistics query on an otherwise idle databaseMeasure what monitoring costs, and tune its queries and interval.
Parallel queries fail in a containerDocker's default 64 MB of shared memoryRaise shm_size.

Questions

  1. Why do migration tools use random identifiers linked to a parent rather than sequential numbers?

    Answer

    So two branches can each add a migration without colliding. If both are merged, the tool reports two heads, and a small merge migration joins them.

  2. What does autogenerate get wrong that a person must fix?

    Answer

    Renames, which it writes as a drop plus an add and so loses data; data migrations, which it never writes; some constraint and default changes; and index builds, which it writes as blocking statements.

  3. Why does a migration need a lock timeout?

    Answer

    A schema change waiting for a lock makes every later query on the table queue behind it, so a short wait becomes an outage. With a timeout the change gives up and can be retried.

  4. The severity query was 3.8 ms after the first index. Why not add a second index that made it 0.063 ms?

    Answer

    The query already met its target, and the second index would have roughly doubled the cost of every insert and added 78 MB. The trigger for adding it was written down instead.

7

Part III · Data

Connections, pooling and row-level security

Database connections are a budget shared by every worker and replica, a pooler stretches that budget at the price of per-session state, and row-level security makes Postgres itself refuse rows a caller should not see, so a forgotten filter returns nothing instead of leaking.

At a glance
  • Connections multiply: replicas × workers × pool size. Postgres allows 100 by default, and each costs a server process.
  • A transaction-mode pooler (PgBouncer, RDS Proxy) lets many client connections share a few server connections, so nothing may rely on session state between transactions.
  • Two roles: the schema owner runs migrations; the application's role can read and write rows but not change the schema.
  • A pool that discards and recreates connections under bursts can trap a service in a slow state that sustains itself. A fixed-size pool ends it.
  • Row-level security is the second barrier behind the application's own filter: policies read who is asking from transaction-local settings.
Then and now
ThenNowWhy it changed
One application server, one connection poolMany workers and replicas, a pooler in front of the databaseConnections grow with every replica; the database's limit does not.
One database user for everythingAn owner role for migrations, a restricted role for the applicationA bug or an injection in the app cannot drop a table.
Tenant isolation only in application codeThe same rule enforced by the database with row-level securityOne forgotten WHERE should be an empty result, not a leak.
Timeouts set per sessionTimeouts set on the database roleA transaction-mode pooler discards session settings.

The connection budget

api replica 12 workers × pool 20 api replica 22 workers × pool 20 … up to about 1240 connections each PgBouncertransaction poolingup to 500 clients20 server connections Postgres100 connections by default,one process each many client connections a few serverconnections replicas × workers × pool ≤ the pooler's client limit; the pooler's server pool ≤ Postgres' limit minus headroom for migrations and admins
Figure 7.1The reference service's connection budget. Without the pooler, each replica's 40 connections would exhaust Postgres at three replicas. On AWS, RDS Proxy can play the pooler's role, but where it sees session state it pins the client to one server connection until the client disconnects. Its PostgreSQL triggers include set_config, which this service calls in every signed-in transaction, so measure pinning before sizing on it.

Transaction pooling has one hard consequence: a server connection is handed to a different client at the end of every transaction, so nothing may rely on session state. Session SET commands, advisory locks held across transactions, LISTEN/NOTIFY and temporary tables all break. That is why the reference service sets its statement and idle timeouts on the database role itself, and why it tells Postgres who is asking in every transaction rather than once per connection.

ONE SERVER CONNECTION, TWO CLIENTS IN TURN Session-level SET user A: SET team = payments; query user B's transaction still sees team = payments Transaction-local setting user A: set_config(..., true); query ended with A's transaction; B starts with nothing Under row-level security, "nothing" means no rows: a query that forgets to say who is asking sees and changes nothing. The setting reads NULL before it is ever set on a connection, and '' after a transaction that set it has ended, so the policy must treat both as "no access".
Figure 7.2Why the tenant context must be transaction-local. Through a transaction pooler, the next transaction on a server connection may be another user's, and a session-level setting would still be there for them.

A pool that traps itself

Load tests of the reference service showed two tools disagreeing by a factor of 40: about 90 ms per request under one load generator and 2 ms under another, at the same rate. The difference was the shape of the arrivals, and what it did to the connection pool.

Bursty arrivalspush the pool into overflow Overflow connectionsare discarded when returned New loginsabout 12.6 ms each Slower requestskeep concurrency high the slow state sustains itself Fix: a fixed pool, 20, no overflowlogins 196 → 5; p50 97 → 35 ms
Figure 7.3A metastable failure: a slow state kept alive by its own slowness, after the trigger is gone. Fix the feedback loop (a fixed-size pool), not only the trigger.

Row-level security: the second barrier

The application already filters every read by the caller's teams and checks every write's role. That is one line of code per query, and the next endpoint, a refactor or a reporting query could forget it, silently returning another team's data, perhaps into the assistant's prompt. Row-level security moves the same rule into Postgres: each table holding team data has policies, one per command, that read who is asking from transaction-local settings. A query that forgets the filter returns nothing instead of leaking.

Session cookie→ user and teams,in one query Barrier 1: the appfilters by the caller'steams; 404 otherwise Barrier 2: Postgrespolicies read thetransaction's settings Only thecaller's rows set_config('app.read_team_ids', '{3,7}', true) a forgotten filterin a new endpoint is still filtered here: no leak and no context at all means no rows
Figure 7.4Defense in depth for multi-team data. The application answers first and is the main control; the policies are the backstop that turns a coding mistake into an empty result.
In the repoapps/api/app/db.pyand the policies inmigrations/…_row_level_security_on_alerts.py

The database session tells Postgres who is asking at the start of every transaction, and the migration defines one policy per command.

  • set_config(..., true): the third argument makes the setting transaction-local, so it cannot leak through the pooler.
  • The policies read the settings through a scalar subquery, which Postgres evaluates once per query instead of once per row: 44 ms against 100 ms over a million rows.
  • An empty or unset setting becomes an empty list: no access, not an error.
  • There is no UPDATE policy because the app never updates alerts, so an update affects no rows.
def _set_config(values: dict[str, str]) -> Select[Any]:
    return select(*(func.set_config(k, v, True) for k, v in values.items()))


@event.listens_for(ContextSession, "after_begin")
def _apply_transaction_settings(
    session: Session, transaction: SessionTransaction, connection: Connection
) -> None:
    values: dict[str, str] | None = session.info.get("transaction_settings")
    if values:
        connection.execute(_set_config(values))


async def set_transaction_settings(session: AsyncSession, values: dict[str, str]) -> None:
    """Applied to the open transaction, if any, and to every later one."""
    session.info["transaction_settings"] = values
    if session.in_transaction():
        await session.execute(_set_config(values))
ORG_ADMIN = "(SELECT current_setting('app.org_admin', true)) = 'on'"
SERVICE = "(SELECT current_setting('app.service', true)) = 'alertmanager'"


def teams(setting: str) -> str:
    # The cast keeps ANY in its array form: `= ANY ((SELECT ...))` without
    # it is the subquery form, comparing bigint with bigint[].
    return f"team_id = ANY ((SELECT tenant_team_ids('app.{setting}'))::bigint[])"


def upgrade() -> None:
    op.execute(
        """
        CREATE FUNCTION tenant_team_ids(setting text) RETURNS bigint[]
        LANGUAGE sql STABLE PARALLEL SAFE
        AS $$ SELECT coalesce(nullif(current_setting(setting, true), ''), '{}')::bigint[] $$
        """
    )
    op.execute("ALTER TABLE alerts ENABLE ROW LEVEL SECURITY")
    op.execute(
        f"CREATE POLICY alerts_select ON alerts FOR SELECT "
        f"USING ({ORG_ADMIN} OR {SERVICE} OR {teams('read_team_ids')})"
    )
    op.execute(
        f"CREATE POLICY alerts_insert ON alerts FOR INSERT "
        f"WITH CHECK ({ORG_ADMIN} OR {SERVICE} OR {teams('write_team_ids')})"
    )
    op.execute(
        f"CREATE POLICY alerts_delete ON alerts FOR DELETE "
        f"USING ({ORG_ADMIN} OR {teams('admin_team_ids')})"
    )

The companion decision, ADR-0005, set the two-role model and the pooler: the schema owner is used only by migrations, which run directly against Postgres as a one-off step before the api starts, never on api startup, where several workers and replicas would race to migrate. The Compose deploy does exactly that. The reference ECS stack instead migrates in an init container in every api task, so replicas that start together did race, and an advisory lock now makes them take turns (Chapter 6).

Traps

SymptomCauseFix
The pooler looks healthy but every query failsIt authenticated with md5 while Postgres requires SCRAM; pg_isready does not authenticateConfigure SCRAM; make the readiness check run a real query.
One user's tenant setting appears in another user's requestA session-level SET through transaction poolingTransaction-local set_config(..., true), with a test.
After a network fault, every request waits for a pooled connectionThe container came back on another address; its pooled connections hang, holding their slotsA TCP user timeout on database connections, so dead ones fail and are replaced (Chapter 15).
Per-session timeouts silently do nothingTransaction pooling discards session settingsSet timeouts on the database role.
Latency stays high after a burst is overOverflow connections discarded and recreated: a metastable loopA fixed-size pool with no overflow.
A pool-usage gauge said 2 while 10 requests were stuckIt counted checkout events, which fire only after the connection's pre-ping succeedsSample the pool's own accounting.
A test's cleanup deleted nothingUnder row-level security, a query without context sees no rowsDeclare the context in fixtures; test as the app role, because the owner bypasses policies.
A policy is slow on large tablesA function call in the policy runs once per rowWrap it in a scalar subquery so it runs once per query.
An insert is refused although the write policy allows itINSERT … RETURNING is also checked against the SELECT policyMake sure the writer may read its own row, or insert without returning.
After an upgrade, a running older release sees no rowsThe contract migration enabling policies ran before every instance sent the contextExpand first (send the context), contract in the next release; never skip it.

Questions

  1. What breaks when an application moves behind a transaction-mode pooler?

    Answer

    Anything relying on session state between transactions: session SET, advisory locks held across transactions, LISTEN/NOTIFY, and temporary tables. Each transaction may run on a different server connection.

  2. Why must the tenant setting be transaction-local?

    Answer

    Because the pooler hands the same server connection to another client after the transaction ends. A session-level setting would still be there for them; a transaction-local one ends with the transaction.

  3. What does "metastable" mean, and how did the reference service escape it?

    Answer

    A slow state that keeps itself going after its trigger is gone. Discarded overflow connections forced new logins, which slowed requests and kept concurrency high. A fixed pool with no overflow broke the loop.

  4. If the application already filters by team, what does row-level security add?

    Answer

    A second barrier that holds when the application forgets: a missing filter returns an empty result instead of other teams' rows, and a query that says nothing about the caller sees nothing.

8

Part IV · Identity and protection

Sign-in, sessions and roles

Users sign in with the organization's identity provider through OpenID Connect, the server verifies the result and issues its own session, and every request is authorized from roles the provider asserts, so the application never handles a password and the browser never holds an access token.

At a glance
  • The authorization code flow with PKCE, with the server as a confidential client (the backend-for-frontend pattern): the browser carries a one-time code and nothing else.
  • The server verifies the ID token itself: signature, issuer, audience, expiry and nonce. Each check stops a specific attack.
  • The session is a random token in a cookie, with only its hash in the database. It can be revoked instantly, unlike a JWT.
  • Roles come from the provider's groups claim and are replaced at every sign-in; they are ranked per team.
  • A resource the caller cannot see is a 404, never a 403: a 403 confirms it exists.
Then and now
ThenNowWhy it changed
A users table with password hashes in every applicationSingle sign-on with the organization's identity provider over OIDCOne password, the organization's MFA, and access removed in one place when someone leaves.
Access tokens stored in the browser's local storageTokens kept on the server; the browser holds only an HttpOnly cookieA script injected into the page has nothing to steal.
The implicit flow, tokens in the URLThe authorization code flow with PKCEA stolen code is useless without the verifier only the server holds.
JWTs as sessions everywhereServer-side sessions for web apps; JWTs for what they suitSessions can be revoked at once, which also makes a role change take effect at once.
Roles maintained in the application's database by handRoles from the provider's groups, replaced at each sign-inAccess is managed where people are managed.

The sign-in flow

Browser api (confidential) Identity provider 1 GET /api/auth/login 2 makes state, a nonce and a PKCE verifier;keeps them for 10 minutes redirect to the provider, with the challenge 3 the provider's sign-in page password and MFA: the appnever sees either 4 redirect back: a one-time code, the state GET /api/auth/callback?code=…&state=… 5 state matches the cookie? 6 back channel: code + verifier + client secret 7 ID token (the access token is dropped) 8 verify: signature, issuer, audience,expiry, nonce; replace roles from thegroups claim; store the session's hash 9 Set-Cookie: __Host-session (HttpOnly) after sign-in, the browser holds only a cookie
Figure 8.1The authorization code flow with PKCE, the server acting as a confidential client. The code is exchanged over the server's own connection to the provider, which the browser never sees; a stolen code is useless without the verifier and the client secret.

What each check stops

CheckStops
state, bound to a short-lived cookie in this browserLogin CSRF: being signed in as the attacker without noticing
nonce, inside the ID tokenReplay of an old ID token
PKCE verifier (S256)Interception of the authorization code
The issuer, from the discovery document and on the callbackIdentity-provider mix-up
An algorithm allow-list: asymmetric onlyalg: none, and HMAC signed with the public key as the secret
aud and azpA token minted for a different application
exp and iat, with a small leewayStale tokens, while tolerating clock skew
Origin equal to the public URL on every state-changing requestCross-site request forgery, with the cookie's SameSite setting as a second layer
In the repoapps/api/app/oidc.pythe ID token, verified by the server itself

The only module that knows the OIDC protocol, in about 300 lines, every check tested by failing it one at a time against a fake provider. The verification step:

  • The algorithm is checked against an allow-list before anything else, so a token cannot choose a weak one.
  • The signature is checked against the provider's published key for that key id; issuer, audience, expiry and issued-at are required, with a leeway for clock skew.
  • The authorized party must be this client, and the nonce is compared in constant time.
async def verify(self, id_token: str, *, nonce: str) -> Identity:
    try:
        header = jwt.get_unverified_header(id_token)
    except jwt.PyJWTError as exc:
        raise InvalidToken("malformed ID token") from exc
    algorithm = header.get("alg")
    if algorithm not in ALGORITHMS:
        raise InvalidToken(f"ID token algorithm {algorithm!r} is not accepted")
    key = await self._signing_key(header.get("kid"))
    client_id = self._settings.oidc_client_id
    try:
        claims: dict[str, Any] = jwt.decode(
            id_token,
            key=key.key,
            algorithms=[algorithm],
            audience=client_id,
            issuer=self._settings.oidc_issuer,
            leeway=LEEWAY_S,
            options={"require": ["exp", "iat", "iss", "aud", "sub"]},
        )
    except jwt.PyJWTError as exc:
        raise InvalidToken(f"ID token rejected: {exc}") from exc
    audiences = claims["aud"] if isinstance(claims["aud"], list) else [claims["aud"]]
    if claims.get("azp", client_id) != client_id or (
        len(audiences) > 1 and "azp" not in claims
    ):
        raise InvalidToken("ID token was issued to another client")
    if not hmac.compare_digest(str(claims.get("nonce", "")), nonce):
        raise InvalidToken("ID token nonce does not match this sign-in")

Server sessions, not JWT sessions

Server-side session (the reference) cookie: 32 random bytes, HttpOnly, Secure, SameSite=Lax database: only the token's SHA-256 each request: one lookup, in the query it makes anyway roles: as of the last sign-in, read per request sign-out or revoke: a DELETE, effective at once ends after 12 hours, or 2 idle hours no salt needed: 256 random bits have nothing to guess, unlike a password A JWT as the session saves one database read per request, but: cannot be ended before it expires, not even by signing out, without a list of revoked tokens: a session table again carries the roles it was issued with signed, not encrypted: anyone holding it can read its claims adds a signing key to guard and rotate
Figure 8.2The reference service uses exactly one JWT: the provider's ID token, checked once at sign-in and kept on the server; the browser sees it only once, as the hint in the sign-out redirect. Its own sessions are plain random tokens, which can be revoked with a single delete.

The cookie uses the __Host- prefix, which makes the browser refuse it unless it is Secure, has path / and no domain, so no subdomain can set or overwrite it. SameSite=Lax, not Strict: the provider's redirect back to the callback is a cross-site navigation, and a Strict cookie would be left behind on it. Passwords never reach the application: the provider stores them salted and hashed. Salts protect secrets that people choose and attackers can guess; a 256-bit random session token has nothing to guess, so a plain SHA-256 is enough and a slow salted hash would only slow every request.

Roles, and 404 versus 403

viewer responder admin read, ask+ create alerts+ delete, members ranked per team: each can do everything below it groups: ["team:payments:responder", "org:admin"] replaced at every sign-in; users keyed by issuer + subject Can the caller seethe team at all? 404as if absent Is the role highenough? 403role too low Allowed no yes no yes
Figure 8.3Ranked roles and the two refusals. A 403 confirms that a resource exists, so anything the caller cannot even see answers exactly like something that does not exist.
In the repoapps/api/app/access.pyranked roles, and the two refusals

The authorization model has no I/O at all, so it is unit-tested on its own. Roles are an integer enum, so "at least responder" is a comparison.

  • require_role answers 404 when the caller cannot see the team, the same as for a missing resource, so ids cannot be probed.
  • It answers 403 only when the caller can see the team but holds too low a role.
  • Postgres enforces the same rule again with row-level security (Chapter 7).
class Role(IntEnum):
    VIEWER = 1
    RESPONDER = 2
    ADMIN = 3
def require_role(principal: Principal, team_id: int, needed: Role, what: str) -> None:
    """404 when the caller cannot see the team - the same answer as for a
    resource that does not exist, so ids cannot be probed - and 403 when
    they can see it but their role is too low."""
    role = principal.role_in(team_id)
    if role is None:
        raise HTTPException(status_code=404, detail=f"{what} not found")
    if role < needed:
        raise HTTPException(status_code=403, detail=f"needs the {needed.label} role in this team")

Traps

SymptomCauseFix
Sign-in works with the demo provider and fails with the real oneSameSite=Strict drops the cookie on the provider's cross-site redirect backUse Lax, and pin it with a test; check Origin for CSRF.
Tokens are rejected for naming the wrong issuerIn Docker the provider has a public address for browsers and an internal one for the apiConfigure the provider with one public issuer, and point the api's discovery at the internal address.
The first sign-in on a new container fails with "unknown key""Never fetched" was stored as time 0, but a monotonic clock counts from boot, so a machine up for under a minute read it as "fetched just now"Mark "never" as minus infinity, not 0.
The token library cannot load the provider's keysThe key set also contains an encryption keyKeep only signing keys (use: sig).
The provider was down and nothing failed or alertedCached metadata hid the outage until the next sign-inA periodic reachability check, reported in readiness, with an alert.
Sign-out leaves the cookie in placeDeleting a __Host- cookie needs Secure on the deleting header tooSend the same attributes when clearing it.
A client secret with + or % fails against strict providersThe client id and secret must be form-encoded before base64 in basic authenticationEncode them as the standard requires.
Entra ID groups arrive as unreadable idsIts groups claim holds object ids, not namesUse app roles for the claim; Google has no groups claim at all.
A user's identity changed when their email changedUsers were keyed by emailKey users by issuer and subject.
With sign-in, the cheapest request collapsed at 1,000 requests a secondA query per table, each in its own transactionOne query in the request's transaction: p95 from 1.07 s to 19.7 ms at 1,000 requests a second. The CPU still doubles; that is the accepted cost of signing in.

Questions

  1. In the code flow with a confidential client, what does the browser ever hold?

    Answer

    A one-time authorization code on the way back from the provider, and then only the application's session cookie. The access token is dropped; the ID token stays on the server until sign-out, when it rides once in the provider's sign-out URL as id_token_hint (OIDC RP-Initiated Logout).

  2. What do state, the nonce and PKCE each protect against?

    Answer

    state stops login CSRF by tying the provider's answer to this browser; the nonce stops ID token replay; PKCE stops an intercepted code from being exchanged by anyone without the verifier.

  3. Why does the reference service prefer server-side sessions to JWTs?

    Answer

    A server session can be revoked with a delete, which also makes a role change take effect at once; otherwise its roles are as of the last sign-in. A JWT cannot be ended before it expires without a revocation list, so its stale roles last until it expires; it also exposes its claims and adds a key to manage.

  4. When should a request answer 404 rather than 403?

    Answer

    When the caller cannot see the resource at all. A 403 would confirm it exists. 403 is for a caller who can see it but whose role is too low.

9

Part IV · Identity and protection

Secrets, personal data and least privilege

Secrets are generated, injected at runtime and never stored where code lives; every component gets only the rights its job needs; and the personal data a service holds is inventoried, retained for a stated time and deletable on request.

At a glance
  • Secrets never go in Git, images, logs or tickets. A secret that reached any of them is rotated, not just deleted.
  • Least privilege at every layer: database roles, container settings, network exposure and HTTP headers.
  • Production refuses to start with unsafe settings, including any secret still at its example value.
  • A personal-data inventory lists what is held, where, why, who can read it, for how long, and how it is deleted.
  • Data that leaves the service (to a model provider, an identity provider, telemetry) is listed too, because the provider's terms then apply.
Then and now
ThenNowWhy it changed
Shared passwords in a config file or a wiki pageGenerated secrets, one per purpose, injected at runtime from a storeNobody needs to know a secret for it to work, and each can be rotated alone.
Containers running as root with full capabilitiesNon-root, read-only filesystem, all capabilities droppedA compromised process can do far less.
Every port reachable inside the data centerOnly the edge published; everything else on private networksAn internal service that cannot be reached cannot be attacked from outside.
Personal data kept "just in case"An inventory with purposes, retention periods and deletion pathsData protection law expects it, and data you do not hold cannot leak.

Where secrets live

Laptop.env made from the example,every secret generated;owner-only permissions CIgenerated per run fora throwaway stack,deleted afterwards A VMgenerated at first boot;provider keys typed once;deploy user only Managed platforma secret store injectsthem as environmentvariables at start Neverin Git, in an image, in a log line, in a chat or a ticket. A secret that reached any of them is burned: rotate it. Typed in code as secrets, they print as asterisks; an empty webhook token disables the webhook rather than accepting anything; production refuses to start while any secret still has the example file's value.
Figure 9.1Where the reference service's secrets come from in each environment. Only the two provider credentials, the model key and the identity provider's client secret, are typed by a person; everything else is generated, and on AWS, generated by Secrets Manager itself (Handbook 3, Chapter 5).

Each secret proves one thing to one party. The identity provider's client secret proves the api is the registered application; the database passwords identify the api, the schema owner and the metrics exporter; the model key identifies the service to its provider; the alert webhook's token identifies Alertmanager, a service, which is why it is a bearer token compared in constant time rather than a session. On a public repository, a secret pushed in a commit has been copied the moment it was pushed, so rotation comes first.

Least privilege, layer by layer

Database rolesowner: migrations only · app: rows only, cannot DROP, CREATE or ALTER, with its own timeouts · monitor: statistics only Containersnon-root (UID 10001) · read-only root filesystem with a tmpfs /tmp · cap_drop ALL · no-new-privileges · limits, no swap · no dev tools Networkonly the edge is published · /metrics and the webhook answer 404 from outside · dashboards and debug ports on loopback only HTTPTLS 1.2 and 1.3, HSTS · CSP default-src 'self', no inline scripts · frames denied · nosniff · no server version · 1 MB bodies · rate limits
Figure 9.2Least privilege at four layers of the reference service. Each forbidden operation was tried and refused; the image checks run in CI, and the browser tests fail on any Content Security Policy violation.

The host matters too. Membership of the docker group is root-equivalent, so only the deploy user has it; SSH takes keys only, never passwords, and root login is off; security updates install themselves. A debugger port is remote code execution, so the debugging overlay is a separate Compose file bound to loopback and never present in production. A profiler that needs to read another process's memory runs as a separate container borrowing the api's namespaces, on demand, so the api itself never holds that capability.

In the repoinfra/postgres/initdb/10-app-role.shthe application's database role

The script that creates the application's role on a new database. It gets rows, not schema, and carries its own limits.

  • The timeouts are set on the role because the transaction pooler discards session-level settings (Chapter 7).
  • Default privileges extend the grants to every table a future migration creates, so nobody has to remember to grant.
  • The script runs once, on an empty data volume; a change to an existing database is a migration.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
  -v db="$POSTGRES_DB" -v app_user="$APP_DB_USER" -v app_password="$APP_DB_PASSWORD" <<'SQL'
CREATE ROLE :"app_user" LOGIN PASSWORD :'app_password';

-- Server-side limits for every connection the app opens. They live on the
-- role because PgBouncer's transaction pooling discards session-level SET.
ALTER ROLE :"app_user" SET statement_timeout = '10s';
ALTER ROLE :"app_user" SET idle_in_transaction_session_timeout = '30s';

GRANT CONNECT ON DATABASE :"db" TO :"app_user";
GRANT USAGE ON SCHEMA public TO :"app_user";

-- Applies to tables/sequences the owner creates later, i.e. every migration.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_user";
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO :"app_user";
SQL

Personal data: an inventory, retention and erasure

A service that holds data about people needs a document that answers a data protection officer, a lawyer, or a person asking what is held about them. The reference service's inventory lists every place personal data lives, and the most important line is the one saying what is not stored: questions and answers.

HELD BY THE SERVICE, WITH RETENTION accounts: until 365 days without a sign-in sessions: 12 hours at most alerts: 365 days (free text can name people) runbooks and their vectors: until a team admin deletes them audit trail: user ids, ids and hashes, never text; pruned by its owner access logs: rotated; traces: in memory, ids only rate-limit counters: 60 seconds backups: the newest 14 dumps questions and answers: not stored placeholder retention periods, set by policy Model providerquestions, visible alerts andrunbook text; credentialsredacted, names are not Identity providerthe sign-in itself Telemetry backendsids, counts, timings only LEAVES THE SERVICE
Figure 9.3The reference service's personal-data inventory, in one picture. Once data leaves for a provider, that provider's terms apply: retention, training use and processing region belong in a data-processing agreement before launch.
  • Access. An operator command exports, as JSON, everything held about a person, across every identity provider, and records that it did so.
  • Erasure. A dry run shows what would be erased; the real run removes accounts, memberships and sessions and records it. The person must also be removed at the identity provider, or their next sign-in recreates them. The audit trail keeps their events under an id that no longer resolves to anyone: pseudonymous, not anonymous, and whether that is allowed is a legal decision to write down.
  • Retention. A scheduled job deletes expired sessions, users who have not signed in for a year, and old alerts; an empty setting keeps that kind forever. Backups keep rows until they rotate out.
  • Rectification. Name and email come from the identity provider at each sign-in, so they are corrected there.

Some questions belong outside engineering and should be asked early: the legal basis for each use; a data-processing agreement with the model provider (no training on your data, how long prompts are kept, where they are processed); a data protection impact assessment; whether a works council must agree to a system that can show what an employee asked; how a breach would be detected in time to report it; and the AI-transparency rule that people must be told they are dealing with an AI system, which the reference chat states above the question box.

Traps

SymptomCauseFix
A leaked secret keeps working after it was removed from the repositoryRemoval does not undo the copies made when it was publicRotate first, then remove.
Secrets appear in a build logmake echoes its recipes, values includedPass secrets to containers by name (-e NAME), taking the value from the environment.
An alert-routing tool cannot read a secret from the environmentIts configuration does not expand environment variablesMount the secret as a file.
Erased users reappearThey still exist at the identity provider and signed in againRemove them there as part of the erasure procedure.
Personal data survives in places nobody listedLogs, traces, backups and counters were not in the inventoryA new table, log field or span attribute holding personal data gets an inventory row and a retention in the same change.
Sensitive facts survived redactionRedaction removes secret formats, not facts such as "payroll failed for 1,200 employees"Keep content out of telemetry by default; treat model inputs as leaving the service.

Questions

  1. Where should a production secret come from, and where must it never appear?

    Answer

    From a secret store or generation at first boot, injected into the environment at runtime. Never in Git, an image, logs, chats or tickets; if it does, rotate it.

  2. Name one least-privilege control at each of the database, container and network layers.

    Answer

    Database: an application role that can change rows but not the schema. Container: non-root with a read-only root filesystem and all capabilities dropped. Network: only the edge published, internal endpoints unreachable from outside.

  3. What makes an erasure request complete in the reference service, and what remains?

    Answer

    Erasing the accounts, memberships and sessions, and removing the person at the identity provider. Audit events remain under an id that no longer resolves (a legal decision), and backups hold the rows until they rotate out.

10

Part V · The web tier

Frontend decisions and streaming

Frontend choices are mostly decisions about what not to add (no meta-framework for an internal tool, no global store for server data), and streaming a model's answer to the browser needs a small, careful protocol: Server-Sent Events over a POST, read with fetch, with heartbeats and a working Stop.

At a glance
  • React is the safe default for its ecosystem and hiring pool; a meta-framework such as Next.js earns its place for public, search-indexed content, not for an internal tool behind sign-in.
  • "State" is two things: server state, owned by the backend and cached with a library such as TanStack Query, and client UI state, which usually needs nothing beyond the framework.
  • Vite and TypeScript are the defaults; Create React App is deprecated.
  • For a model's answer: Server-Sent Events, one way from server to browser over ordinary HTTP. WebSockets only when the client must also stream; polling as a last resort.
  • The browser's built-in EventSource can only make GET requests, so a chat that posts a question reads the stream with fetch and parses it, the way the model vendors' own SDKs do.
Then and now
ThenNowWhy it changed
Redux for everything, including data from the APIA server-state cache (TanStack Query, SWR); local state for the restFetching, caching and invalidation are solved problems; a global store re-solved them by hand.
Create React App and webpackVite: native modules in development, a fast production buildNear-instant dev server start and hot reload; CRA is no longer maintained.
JavaScriptTypeScript, compiled to JavaScriptA class of bugs caught while typing, at no runtime cost.
Request, spinner, full responseStreamed answers, token by tokenA model takes seconds; users should read as it writes.
Selenium end-to-end testsPlaywright, with auto-waiting and tracesFewer flaky waits, and a trace viewer that shows what happened.

Choosing a framework, and what not to add

Public content, wheresearch or first paint matters? Static site or meta-frameworkNext.js, Nuxt, SvelteKit: server rendering A single-page appReact with Vite; the reference service yes no: an app or a tool behind sign-in THE FRAMEWORK ITSELF React: largest ecosystem and hiring pool Vue: gentler curve, more built in Svelte: compiles away, small bundles Angular: full framework, TypeScript first, common in large enterprises All four are component-based; the real differences are ecosystem size and how much the framework decides for you.
Figure 10.1The two frontend questions that matter most. The reference service is an internal tool behind sign-in, so it is plain React built by Vite, and its first decision record explains why it has no meta-framework.

State is two different things

Server state owned by the backend: the alert list, the profile needs: fetching, caching, refetching, invalidation, loading and error states TanStack Query or SWR this covers most of what a typical app's "state" actually is Client state the UI's own: is a dialog open, which tab usually local: the framework's own state and context are enough shared widely: Zustand or Jotai Redux (Toolkit): very large apps with complex client logic, or code already built on it
Figure 10.2The modern answer to "do we need Redux?": usually not. The reference app has no Redux, because nearly all its state is server state, which TanStack Query owns.
ConcernCommon choices today
BuildVite (default); webpack in older projects; Create React App is deprecated
LanguageTypeScript: types erased at build time, so no runtime cost
StylingCSS Modules; Tailwind utility classes; component libraries (MUI, Chakra); shadcn/ui, whose components you copy into your code and own
Unit and component testsVitest or Jest, with React Testing Library, which tests by what a user sees, not implementation details
End-to-end testsPlaywright or Cypress, driving a real browser through real flows; few of them
Package managernpm (default), pnpm (fast, disk-efficient, common in monorepos), yarn; the lockfile is committed, like uv.lock

Streaming to the browser

Server-Sent Events browser server one request one long HTTP response, events as text linesuse for: model tokens, live status WebSockets browser server persistent and two-way; heavier to runuse only when the client must stream too Polling ask, wait, ask again: simple, wasteful and laggy; the fallback when neither fits
Figure 10.3Three ways to push data to a browser. One-way streaming over ordinary HTTP passes through proxies and load balancers that already exist; that is why SSE is the default for model output.

The reference chat streams its answers as Server-Sent Events, and its wire format was redesigned once a real model replaced the placeholder. Plain-text events broke on the first markdown answer: a blank line inside a token ended the event early. A real model can think for tens of seconds before its first token, while proxies and load balancers cut idle connections at around a minute. And a user who closes the tab must stop the provider from generating tokens that are still being paid for.

browser api model POST /chat/stream, the question in JSON event: meta {request_id, alerts} then: streamed completion : keep-alive event: token {"delta": "Check "} event: token {"delta": "the disk"} event: done {usage, ttft_ms} or error heartbeat comments every 15 s of silencekeep proxies from closing the stream whilea reasoning model thinks Stop: the button aborts the fetch → the connection closes → the api cancels its producer task → the provider's stream is closed → generation, and billing, stop Errors before the stream starts (rate limit, validation, database down) are ordinary JSON errors with a status code.
Figure 10.4The reference service's stream. Every event carries JSON on one data: line, so no content can break the framing, and tokens are appended exactly as the model produced them.
In the repoapps/web/src/lib/sse.tsreading a stream that EventSource cannot

A small Server-Sent Events parser, written because EventSource cannot send a POST body. Its comment lists what makes streams hard to read correctly.

  • Network chunks do not line up with events: one read can hold half an event, or several.
  • A chunk can end in the middle of a multi-byte UTF-8 character, so bytes are decoded in streaming mode.
  • Breaking out of the loop, or aborting the fetch, cancels the body and closes the connection: that is how the server learns the user left.
/**
 * Parses a Server-Sent Events byte stream (the parts of the spec the api
 * uses). Written by hand because the browser's EventSource can only GET,
 * and the chat request carries a JSON body.
 *
 * Network chunks do not line up with events: one read can hold half an
 * event, several events, or end in the middle of a multi-byte UTF-8
 * character. So bytes are decoded with `stream: true` (a split character
 * waits for its other half) and text is buffered until whole lines arrive.
 * Lines end in \n, \r\n or \r; a blank line dispatches the event; lines
 * starting with ":" are comments (the api's keep-alive heartbeats).
 *
 * Stopping early (break, or an aborted fetch) cancels the body, which
 * closes the connection - that is how the server learns the user left.
 */
export async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
  const reader = body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let event = 'message'
  let data: string[] = []
  let id: string | undefined

  try {
    while (true) {
      const { done, value } = await reader.read()
      buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })

Traps

SymptomCauseFix
The browser's EventSource cannot send the questionEventSource supports only GET, with no bodyUse fetch and parse the stream.
A streamed answer arrives all at once in productionThe proxy buffers responsesDisable buffering for the stream's location and send X-Accel-Buffering: no.
Markdown answers are cut off mid-streamRaw text in data: lines cannot carry newlinesJSON-encode every event.
Long thinks end in a dropped connectionProxies and load balancers close silent connections (often at 60 s)Heartbeat comments more often than the smallest idle timeout in the path.
The spinner never stopsThe stream ended without a done or error eventTreat an incomplete stream as an error of its own.
Stop resubmitted the questionReact reused one button element for Ask and Stop, and its type changed mid-click; jsdom could not reproduce itDistinct keys; and a real-browser test, which caught it.
Model output could run as HTMLRendering untrusted text as markupRender model output as text; a browser test fails if it is ever rendered as HTML.
Browser tests fail in CI but not locallyThe Playwright Docker image and the npm package versions differPin both to exactly the same version.

Questions

  1. When does a meta-framework such as Next.js earn its place, and when does it not?

    Answer

    It earns it for public content where search ranking or first paint matter, through server rendering. For an internal tool or dashboard behind sign-in, it adds complexity without a benefit; a single-page app built by Vite is enough.

  2. What is the difference between server state and client state, and which tool suits each?

    Answer

    Server state is owned by the backend and needs fetching, caching and invalidation: a library such as TanStack Query. Client state belongs to the UI, such as an open dialog: usually the framework's own state, with a small store only when it is shared widely.

  3. Why does the reference chat use Server-Sent Events rather than WebSockets?

    Answer

    The answer flows one way, from server to browser. SSE is plain HTTP, works through existing proxies and load balancers, and is simpler to run. WebSockets are for when the client must also stream continuously.

  4. What has to happen, end to end, when a user presses Stop?

    Answer

    The browser aborts its fetch, which closes the connection; the server notices the disconnect, cancels the task reading from the model, and closes the provider's stream, so generation and billing stop.

11

Part V · The web tier

The edge: TLS, nginx and proxies

Between the browser and the application sit one or two proxies that terminate TLS, serve files, route requests and absorb restarts, and nearly every subtle production bug at this layer comes from a default that suits batch traffic but not a streaming web app.

At a glance
  • The edge terminates HTTPS with automatic certificates; behind a cloud load balancer, the load balancer does that job instead.
  • nginx serves the built frontend and forwards /api to the api, with buffering off for streams and errors answered in the api's own JSON shape.
  • The real client address must survive both proxies: the outermost overwrites X-Forwarded-For, and inner ones trust it only from known ranges.
  • Timeouts nest: the side that closes idle connections first must be the proxy, and heartbeats must be more frequent than the shortest idle timeout.
  • A proxy that resolves its upstream's name once, at start, will connect to a dead address forever after a redeploy.
Then and now
ThenNowWhy it changed
Certificates bought yearly and installed by handCertificates obtained and renewed automatically over ACMEFree, automated certificates removed the main reason for plain HTTP.
Apache serving pages and running application codeAn edge proxy for TLS, nginx for files and routing, an application server behindEach layer does one job and can be replaced by a managed service.
Short request and response cyclesLong-lived streams that idle while a model thinksProxy defaults for buffering and idle timeouts now matter.
The client IP is the connection's addressThe client IP comes from a forwarded header, trusted only from known proxiesBehind proxies, every connection comes from the proxy.

Two proxies, two jobs

BrowserHTTPS Edge (Caddy) TLS 1.2 and 1.3; certificates over ACME HTTP/2 and HTTP/3; 80 redirects to 443 overwrites X-Forwarded-For passes stream chunks at once holds a request up to 5 s while nginx restarts, then retries routes the sign-in pages to the bundled identity provider on a cloud: the load balancer nginx (web) serves the built React app /api/* to the api, prefix stripped the chat stream: buffering off, 120 s read timeout re-resolves the api's name its own 502/504 as the api's JSON /metrics, webhook: 404 outside security headers, 1 MB bodies apigunicorn behind the edge, plain HTTP on a private network
Figure 11.1The two proxies in front of the reference api. Only the edge is published; nginx listens on a private network. On AWS the load balancer terminates TLS and routes /api itself, so neither the edge nor nginx's proxying is needed there (Handbook 3, Chapter 9).
In the repoapps/web/nginx/default.confandtools/edge/Caddyfile

The settings that make streaming and rolling deploys work through both proxies, each with its reason in a comment.

  • nginx buffers responses by default; for the stream it must not, or the whole answer arrives at once at the end.
  • The read timeout is the longest silence allowed between reads, not the total duration; the api's heartbeats keep it from expiring.
  • The edge flushes every chunk immediately and, while nginx is being replaced, holds and retries requests that could not reach it at all, so nothing is sent twice.
# Streaming (SSE): every event must reach the browser the moment the
# api writes it. Buffering is on by default in nginx and would deliver
# the whole answer at once.
location = /api/chat/stream {
    proxy_pass http://api_backend/chat/stream;
    include /etc/nginx/snippets/proxy.conf;
    proxy_buffering off;
    proxy_cache off;
    # Max silence between two reads from the api, not total duration.
    # The api sends a keep-alive comment while the model is thinking.
    proxy_read_timeout 120s;
}
# Everything else goes to nginx: the React build, /api, the SSE stream.
handle {
	reverse_proxy web:8080 {
		# Pass every chunk on the moment it arrives: answers stream as SSE.
		flush_interval -1
		# While nginx is being replaced (make deploy), hold a request for up
		# to 5s and retry, instead of failing it. Only requests that could
		# not reach nginx at all are retried, so nothing is sent twice.
		lb_try_duration 5s
		lb_try_interval 250ms
	}
}

The client's address, through two proxies

Behind a proxy, every connection the application sees comes from the proxy. If the application used the connection's address, every user would share one address, and one rate limit. The fix is the X-Forwarded-For header, which is only as trustworthy as the chain that sets it.

Proxies differ in what they do with the header. The edge here overwrites it with the address it saw. A cloud load balancer appends that address to whatever the client sent. Behind an appending proxy, the application must trust only the proxy's addresses and take the right-most entry it does not trust. Trusting every address (*) takes the left-most entry, and the client wrote that one. The reference service's AWS stack had exactly this bug until a review: behind the load balancer, any client could choose its own address, and with it its own sign-in rate limit.

Clientsends a forged header:XFF: 6.6.6.6 Edgeoverwrites it with theaddress it actually saw nginxtrusts XFF only fromthe proxies in front apione address: thereal client Append instead of overwriteand the forged 6.6.6.6 survives to the application: a client picks its own rate-limit bucket. Behind a cloud load balancer, which appends, trust only its subnet and take the rightmost address it added. Measured: a request sent with a forged header reached nginx and the api as the real client, directly and through the edge.
Figure 11.2Overwrite at the edge, trust only known proxies inside. Before this was set up, the api saw the edge's address for every user, which made the per-user rate limit a single global one.

Timeouts and keep-alive, in the right order

Idle keep-alive: the proxy closes first nginx 60 s gunicorn 75 s otherwise: a reused,just-closed connection: 502 One hop out, the same rule: edge 120 s and load balancer 130 s, so nginx keeps its own clients 140 s. Streaming: heartbeats beat every idle timeout heartbeat 15 s load balancer 60 s nginx stream 120 s A request: the sum stays under the outer timeout pool wait 5 s query 10 s (on the role) nginx 30 s
Figure 11.3Three orderings the reference service keeps, drawn to scale. Break the first and POST requests fail with sporadic 502s; break the second and long thinks end in dropped streams; break the third and nginx gives up on requests the api is still working on.
In the repoapps/web/nginx/default.confand what it includes, written at start-up by25-runtime-conf.sh

Three things depend on where the container runs, so a short script writes them when it starts, and the config includes them.

  • The resolver comes from the container's /etc/resolv.conf: Docker's embedded DNS in Compose, the cluster's DNS on Kubernetes. It is re-queried every 10 seconds, so a recreated api container with a new address is found without restarting nginx, and nginx starts even before the api exists.
  • The real client address is taken from the forwarded header only when the connection comes from a trusted proxy: REAL_IP_FROM, or by default the container's own network, where the edge runs. It once trusted every private range. Behind a load balancer that appends to the header, a client with a private address was then itself trusted, and could choose its own address.
  • The api's address is a setting too. On Kubernetes it must be the service's full name, because nginx's resolver ignores search domains. The upstream's idle keep-alive is shorter than gunicorn's, so the proxy always closes first.
# Written at start-up by /docker-entrypoint.d/25-runtime-conf.sh, from where
# the container runs: the resolver (from /etc/resolv.conf: `resolve` below
# re-resolves the api at runtime, so a recreated api container is picked up
# without an nginx restart, and nginx starts even if the api does not exist
# yet), the trusted proxies (REAL_IP_FROM, else this container's networks)
# and the api_backend upstream (API_UPSTREAM, else api:8010).
include /tmp/nginx/resolver.conf;
include /tmp/nginx/upstream.conf;

# Behind the TLS edge or a cloud load balancer, the connection comes from
# the proxy: take the client's address from the X-Forwarded-For it sets, or
# every user shares one address - and one rate limit. Believed only from the
# trusted proxies; the edge overwrites whatever X-Forwarded-For a client
# sends, so it cannot be forged through it.
include /tmp/nginx/real-ip.conf;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set -eu
out=/tmp/nginx
mkdir -p "$out"

ns=$(awk '$1 == "nameserver" { printf "%s ", ($2 ~ ":" ? "[" $2 "]" : $2) }' /etc/resolv.conf)
echo "resolver ${ns:-127.0.0.11} valid=10s ipv6=off;" > "$out/resolver.conf"

trusted=${REAL_IP_FROM:-$(ip -o -f inet route show scope link | awk '{ print $1 }')}
: > "$out/real-ip.conf"
for cidr in $trusted; do
  echo "set_real_ip_from $cidr;" >> "$out/real-ip.conf"
done

cat > "$out/upstream.conf" <<CONF
upstream api_backend {
    zone api_backend 64k;
    server ${API_UPSTREAM:-api:8010} resolve;
    keepalive 16;
    # Below gunicorn's keepalive: the side that closes idle connections
    # first must be the proxy, never the backend.
    keepalive_timeout 60s;
}
CONF

Traps

SymptomCauseFix
nginx keeps connecting to a dead address after the api is recreatedIt resolved the upstream's name once, at startA runtime resolver and resolve on the upstream server.
Bursts of 502 on POST requestsThe backend closed an idle connection the proxy reused, and nginx does not retry a POSTThe proxy's idle keep-alive shorter than the backend's.
A request waits 30 seconds when the backend has vanishedOn an existing keep-alive connection it waits the read timeout, not the 2-second connect timeoutKnow which timeout applies; keep read timeouts tight on ordinary routes.
Every user shares one rate limitThe application sees the proxy's addressOverwrite the forwarded header at the edge; trust it inside only from known proxies.
Clients can choose their own rate-limit bucketThe edge appended to X-Forwarded-For instead of overwriting itOverwrite at the outermost proxy you control.
Errors from the proxy are HTML pages that clients cannot parsenginx's own 502 and 504 pagesAn error page that answers in the api's JSON shape, keeping the status and request id.
Security headers disappear on one routeAn add_header in a location discards every inherited oneInclude the shared security headers in every location.
Absolute URLs lose their port$host drops the portUse $http_host where the port matters.
Trailing-slash redirects point at the wrong host and port behind a prefix-stripping proxyThe framework builds redirects from what it seesTurn off automatic slash redirects.
The edge container refuses to start with no-new-privilegesThe official image's binary carries a file capability, which the kernel then refusesRemove the capability; let it bind low ports through the unprivileged-port sysctl.
nginx advertises its versionserver_tokens defaults to onTurn it off.

Questions

  1. Why must the proxy close idle keep-alive connections before the backend does?

    Answer

    If the backend closes first, the proxy may send a new request on a connection that was just closed. nginx does not retry a POST on a reset connection, so the user gets a 502.

  2. How does the application get the real client address through two proxies, safely?

    Answer

    The outermost proxy overwrites X-Forwarded-For with the address it actually saw; inner proxies accept the header only from the addresses of the proxies in front of them, not from every private range, because internal clients have private addresses too, and pass on exactly one address.

  3. What two proxy settings does a streamed answer need?

    Answer

    Buffering turned off, so each event is passed on immediately, and a read timeout longer than the gaps between events, with heartbeats from the server shorter than every idle timeout in the path.

12

Part VI · Containers

Containers and Compose

An image is an immutable, content-addressed snapshot built in cached layers; a container is a process running from it; and Compose describes a whole stack as one file per shared shape plus a small overlay per environment.

At a glance
  • Image is to container as class is to object. A tag is a movable pointer, like a branch; the digest is the identity.
  • Order a Dockerfile from what changes least to what changes most: dependency manifests and their install before the code.
  • Multi-stage builds keep compilers, package managers and dev tools out of the image that ships.
  • EXPOSE is documentation; the application's bind creates the listener; only -p opens anything on the host, and it bypasses the host firewall.
  • Compose: a base file every environment shares, an override merged automatically in development, a production overlay named explicitly.
Then and now
ThenNowWhy it changed
Servers configured by hand, drifting apartImages built once, run anywhere unchangedThe artifact that passed the tests is the artifact that runs.
One fat image with the build tools insideMulti-stage builds; a slim, non-root final stageSmaller to pull, fewer vulnerabilities to patch, less for an attacker to use.
A wiki page of docker run flagsA Compose file in the repository, with an overlay per environmentThe whole stack is versioned and reviewed like code.
Base images by tag (python:3.13)Base images pinned by digest, updated by a botA tag can move under you; a digest cannot.

Images, layers and the cache

LAYERS, BOTTOM UP FROM python:3.13-slim@sha256:… (pinned) system packages, users COPY pyproject.toml uv.lock install dependencies (slow) COPY app/ (changes on every commit) a code change rebuilds only this a dependency change rebuildsfrom the install upward Copy the code before installing, and every commit reinstalls every dependency. FOUR WORDS image: the immutable build output, like a class container: a running process from it, like an object tag: a movable name, like a branch; a rebuild moves it digest: sha256 of the content; the real identity logs, exec, stop: containers run, push, tag: images
Figure 12.1An unchanged instruction reuses its cached layer, and a changed one invalidates every layer above it. A .dockerignore (virtual environments, .git, caches) matters as soon as anything copies a whole directory.
depsa virtual environment withruntime dependencies only dev+ dev tools, a user with yourUID, hot reload over the source production: a fresh, pinned slim base numeric non-root user (UID 10001) · pip removed · only the virtual environment and code copied in code owned by root, read-only to the app · breakpoints disabled · a health check compilers, caches and dev tools never reach it COPY --from=deps used by make in development;never pushed to a registry
Figure 12.2Three stages, one Dockerfile. Both images start from the same deps stage and lockfile, so their runtime dependencies are identical; the dev image adds the dev group on top, which is why CI also builds and checks the production image.
In the repoapps/api/Dockerfilethe production stage

The stage that ships. Each line carries the reason for a decision that is easy to get wrong.

  • A numeric user, because Kubernetes can only verify that a numeric USER is not root.
  • pip is removed: the environment is complete, pip's vendored libraries carry their own vulnerabilities, and an attacker could use it to install more.
  • A forgotten breakpoint() would freeze a worker waiting for keyboard input; here it is a no-op.
  • The health check uses Python, because the slim image has no curl.
# ---- production -------------------------------------------------------------
FROM python:3.13-slim@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b AS production
# Numeric UID: Kubernetes' runAsNonRoot can only verify a numeric USER.
# pip goes: the virtualenv below is complete, and the base image's pip
# carries its own vendored libraries with their own vulnerabilities
# (msgpack, setuptools' pkg_resources: make scan) - and it lets whoever gets
# code execution install more.
RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app \
 && python -m pip uninstall --yes --root-user-action ignore pip
WORKDIR /api
# Same path as in the deps stage: a venv's scripts hard-code its location.
COPY --from=deps /api/.venv /api/.venv
# Code stays owned by root and read-only to the app user, so a compromised
# process cannot rewrite it.
COPY alembic.ini gunicorn.conf.py ./
COPY migrations/ ./migrations/
COPY app/ ./app/
# PROMETHEUS_MULTIPROC_DIR: one metrics file per worker, summed on scrape
# (app/metrics.py). Must be set before start, never set to "" (the client
# checks only that the variable exists). /tmp is a tmpfs in compose.prod.yaml.
# PYTHONBREAKPOINT=0: a breakpoint() left in the code is a no-op here. With
# the default (pdb) it would stop a worker waiting for keyboard input that
# never comes - the worker hangs until gunicorn kills it.
ENV PATH="/api/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONFAULTHANDLER=1 \
    PYTHONBREAKPOINT=0 \
    PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus
USER 10001:10001
EXPOSE 8010
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8010/health', timeout=2).status == 200 else 1)"]
CMD ["gunicorn", "app.asgi:app", "-c", "gunicorn.conf.py"]

Ports and container networks

THE HOST bridge: a virtual switch, one per Compose project; DNS by service name weblistens on 8080 apilistens on 8010 dblistens on 5432 each line: a veth pair,a virtual cable -p 127.0.0.1:8080:8080one deliberate hole, loopback only Published ports bypass the host firewallDocker's NAT runs before ufw's rules; filter in DOCKER-USER EXPOSE: metadata onlythe app's bind: the listener-p: the only thing thatopens a port on the host
Figure 12.3How a Compose stack is wired on one host. Service names resolve only within one host's Docker networks; spanning hosts is the job of an orchestrator, which is the real reason Kubernetes and ECS exist (Handbook 3, Chapters 9 and 10).

Two error messages tell you where to look. Connection refused means a packet arrived and was actively rejected: the path works, nothing listens there. A timeout means the packet vanished: a firewall, a frozen process, a wrong route. With a correct -p, a refusal points inside the container, at an application bound to 127.0.0.1 instead of all interfaces, or not started. A stopped container also disappears from Docker's DNS, so its clients see a name-resolution error rather than a refusal, and should treat it as "unavailable".

Volumes, restarts and health

ConceptWhat to know
Named volumeDocker-managed storage for data a container owns, such as a database's files. Survives the container being recreated; down keeps it, down -v deletes it.
Bind mountA host directory mapped in, live both ways: the development pattern for hot reload. It pins the directory, not the path, so a directory recreated by git checkout is no longer the one mounted.
Restart policyActs on exit only. unless-stopped restarts after a crash or a daemon restart, not after a manual stop. Docker never restarts a container for being unhealthy; orchestrators do.
Health checkDrives depends_on: condition: service_healthy and Docker's own status. Orchestrators use their own checks: Kubernetes ignores it, and ECS runs only a check written into the task definition, so define it there too. Its output is in docker inspect, not docker logs.
Logsdocker logs holds only the main process's standard output and error, in a file that is unbounded unless rotation is configured.

Compose: one base, an overlay per environment

compose.yamlwhat every environment shares:services, env contract, health,restart, log rotationno ports, no source mounts compose.override.yamldevelopment, merged automaticallydev targets, source bind mounts,ports on 127.0.0.1 compose.prod.yamlnamed explicitly with -frelease images, edge only published,read-only, cap_drop, limits MERGE RULES environment: key by key, overlay wins ports: lists are concatenated !reset removes an inherited entry -f turns off the automatic override make config prints the merged result
Figure 12.4The reference service's Compose layout. The base declares no ports because port lists concatenate: an overlay could add a port, never take one away without the reset tag.

Before this layout, the production shape existed only as two build commands in a document, so it was never run as a whole, and the production-only bugs (a proxy buffering the stream, one rate limit for every user behind the proxy) lived exactly there. Two complete files were rejected because the shared majority drifts apart silently; Compose profiles were rejected because they switch services on and off but cannot change a service's build target, mounts or ports.

In the repocompose.yamlthe environment contract

Compose substitutes ${VAR} itself, when it parses the file, from the .env beside it. A variable the service does not list never reaches the container, whatever .env says; twenty-two of the api's settings were once silently unsettable for that reason.

  • ${VAR:-default} substitutes when the variable is unset or empty; ${VAR-default} only when unset, so an intentionally empty value stays empty.
  • A key listed with no value is passed when set and absent otherwise, so the code's own default applies.
  • A lint check now fails if any application setting is missing from this list.
      # "-", not ":-": an empty value in .env must stay empty (a real provider).
      OIDC_DISCOVERY_URL: ${OIDC_DISCOVERY_URL-http://keycloak:8080/auth/realms/triage/.well-known/openid-configuration}
      OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-triage-web}
      OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
      # The api's other settings (app/config.py; the handbook's settings
      # reference): passed when .env or the shell sets them, and absent
      # otherwise, so the code's default applies - for these first ones, a
      # default that differs in production (APP_ENV=prod, ADR-0023). A
      # variable compose does not list never reaches the container, whatever
      # .env says.
      DOCS_ENABLED:
      OTEL_TRACES_SAMPLER:
      OTEL_TRACES_SAMPLER_ARG:
      CHAT_RATE_LIMIT_FAIL_CLOSED:
      PROD_CHECKS_WAIVED:

Traps

SymptomCauseFix
A port is reachable from the internet despite the host firewallPublished ports are NAT-ed before the firewall's input rulesBind to 127.0.0.1, or filter in the DOCKER-USER chain.
A rebuild after a dependency change still runs the old dependenciesAn anonymous volume over the virtual environment or node_modules survivesRecreate anonymous volumes (--renew-anon-volumes).
Tests run on stale dependenciesdocker compose run never rebuilds an existing imagerun --build.
A memory limit is exceeded with no out-of-memory killWithout a swap limit, the container may swap as much againSet the swap limit equal to the memory limit.
A crashed container stayed downNo restart policy; or docker kill, which counts as a manual stopunless-stopped; simulate crashes by killing the process from the host.
An overlay's port did not replace the base'sPort lists concatenateKeep ports out of the base, or use !reset.
The dev override stopped applyingAny -f turns off the automatic mergeList the override explicitly alongside other files.
A service crashed parsing a setting${VAR:-} passed an empty string, which is not the same as unsetList the key with no value instead.
docker compose up rolled production backThe deployed image tag was not set in the shell or .envRecord the deployed tag where Compose reads it, as the deploy script does.
A script cannot find a value that Compose sees.env is read by Compose, not by your shellAsk Compose (config), and fail when the answer is empty.
Logs are gonedocker run --rm removed them with the containerShip logs off the host; do not rely on the container's file.
Files written in a bind mount are owned by rootThe container runs as rootRun development containers as your own UID.
Image pulls fail across an officeDocker Hub limits anonymous pulls per IP, and an office shares oneLog in, or use a registry mirror.
The database refuses its data directory after an upgradeA new Postgres major version cannot read the old one's filesDump and restore, or pg_upgrade.

Questions

  1. Why are dependency manifests copied and installed before the application code?

    Answer

    Layers are cached, and a changed instruction invalidates every layer after it. With dependencies first, a code change rebuilds only the code layer instead of reinstalling everything.

  2. What do EXPOSE, the application's bind address and -p each do?

    Answer

    EXPOSE is metadata. The bind creates the listener inside the container's network namespace. -p creates NAT rules from the host into the container, and is the only one that opens anything on the host.

  3. Why does the base Compose file declare no ports?

    Answer

    Port lists from overlays are concatenated, not replaced, so a port in the base would be published in every environment. Each overlay adds the ports its environment needs.

  4. Does Docker restart an unhealthy container?

    Answer

    No. Restart policies act only when the process exits. Health matters to depends_on and to orchestrators, which replace unhealthy tasks or pods, but by their own checks (Kubernetes probes, an ECS task-definition health check); the image's HEALTHCHECK alone does not reach them.

13

Part VII · Quality

Testing, code style and review

Each layer of tests earns its place by catching a class of bug the layer below cannot; real services beat mocks wherever they can run; and the style rules that matter most are about where logic lives, how failures are named, and what a comment is for.

At a glance
  • Many fast unit tests on pure decisions, fewer integration tests against real services, a handful of end-to-end tests in a real browser against the production shape.
  • Do not mock the database or the model provider's SDK: the bugs that mattered lived in drivers, poolers and SDK behavior.
  • Some bugs appear only under load, under failure or over hundreds of runs; load tests, failure drills and repeated evals are test layers too.
  • Coverage is a floor that finds unexercised code; only assertions find wrong behavior.
  • A flaky test is reporting a race, in the test or the application. Retrying hides the second kind.
Then and now
ThenNowWhy it changed
Integration tests against a shared test databaseA throwaway stack per run, real services in containers, deleted afterwardsContainers made real dependencies cheap; shared state made tests order-dependent.
Mocks that record callsFakes that work, and real services where possibleMocks test your assumptions about a dependency, and assumptions are where bugs hide.
Style debates in reviewFormatters and linters in CI; review for design and behaviorMachines settle formatting, so people can review what matters.
"Tested locally" in the PRHow it was verified: the commands and the numbersA claim with no command behind it is a hope.

The layers, and what each catches

unit: 181 api, 52 webpure decisions; seconds, no services integration: 115a throwaway stack of real services image checksthe production image e2e: 12 a real browser through the edge,against the production stack ON DEMAND, BEFORE A CHANGE evalsreal models, pass rates over runs load testscapacity and latency under load 22 failure drillseach dependency down, then back release smokethe published images, both CPUs every pull request: unit, integration, image checks, e2e
Figure 13.1The reference service's test layers, with their sizes. Everything on the pyramid runs in CI on every pull request; the layers on the right need real models, a production-sized stack or a published release, and run before the kind of change they protect.
LAYERA BUG ONLY IT CAUGHT Integrationthe pooler could not authenticate to Postgres; its own health check stayed green Image checkproduction image crash-looped: a runtime library was installed only as a dev dependency End to endStop re-submitted the question; the component test passed before and after the fix Load testa missing index: p95 7 s at 40 requests per second, 43–152 ms when tested alone a first sign-in design, a query per table, that collapsed at 1,000 per second Failure drill13 of 40 pool slots leaked forever after Postgres froze under load an identity-provider outage that no request noticed (metadata was cached) Repeated evalsa prompt printed its own instructions 5 times in 200 runs; 110 calls had all passed
Figure 13.2Real bugs from the reference service and the only layer that caught each. Every functional test was green for the load-test and drill findings; the testing guide in the repository lists over two dozen more.

Real services, fakes, and what not to mock

The integration suite starts its own Compose project, with its own containers, network and volumes, so tests never see development data and everything is deleted afterwards. Before the tests run, the migrations build the schema, and a check fails if the models and the migrations disagree. The test database runs with durability turned off, because throwaway data does not need it and every commit then skips the disk flush. Timeouts and limits are set small (a 1-second model read timeout, 0.2-second heartbeats, five requests a minute) so failure paths are fast to test.

  • Do not mock the database. Real SQL through the real driver and pooler; a mocked session would have missed both of the big database bugs.
  • Do not mock the provider's SDK. The mock model is an HTTP server speaking the provider's API, so tests exercise the real SDK, its retries, its streaming and its exceptions; it can also hang, drop mid-stream or return 429 on demand, and it counts canceled streams.
  • Do not fake time when the test is about time. Heartbeat and timeout tests use small real durations, because the bugs live in the interaction with the event loop.
  • Prefer fakes to mocks. A fake is a small working implementation; a mock records calls and ties the test to the implementation.
In the repoapps/api/tests/integration/test_access.pytests named for behavior

Access tests use a cast of signed-in users, one per role, and assert what each one sees through the real api and database.

  • The test names read as bug reports when they fail.
  • Assertions are on what a user sees: messages, status codes, the error code; not on which function was called.
  • Another team's alert answers 404, exactly like a missing one, so ids cannot be probed.
async def test_each_user_sees_only_their_teams(cast: dict[str, AsyncClient]) -> None:
    await post(cast["payments"], "payments", "pay-1")
    await post(cast["platform"], "platform", "plat-1")
    assert await listed(cast["payments"]) == ["pay-1"]
    assert await listed(cast["platform"]) == ["plat-1"]
    assert await listed(cast["viewer"]) == ["plat-1"]
    assert await listed(cast["org"]) == ["plat-1", "pay-1"]
    assert await listed(cast["newcomer"]) == []


async def test_other_teams_alerts_are_not_found_not_forbidden(
    cast: dict[str, AsyncClient],
) -> None:
    secret = await post(cast["platform"], "platform")
    # Same answer as for an id that does not exist: ids cannot be probed.
    for response in (
        await cast["payments"].get(f"/alerts/{secret}"),
        await cast["payments"].delete(f"/alerts/{secret}"),
        await cast["payments"].get("/alerts", params={"team": "platform"}),
        await cast["newcomer"].get(f"/alerts/{secret}"),
    ):
        assert response.status_code == 404
        assert response.json()["error"]["code"] == "not_found"

The minimum for a new endpoint

PathWhat the test asserts
SuccessThe status, the body's shape, and what was stored
ValidationA 422 naming the field that failed
Not found, conflictWhere the endpoint can return them
Dependency downThe database or cache pointed nowhere, or a port that accepts and never answers: a 503 with its error code, not just "not 200"
AccessSigned out 401; another team's resource 404; a role too low 403; a POST from another origin 403
Rate limit and metricsA user of its own for limited routes; a new metric moved, with bounded labels

Code style that linters cannot enforce

A ROUTE: I/O AT THE EDGES readrequest, session,database rows decide: a pure functionplain values in, plain outaccess_from_claimsbuild_messages · score writeresponse, rows,metrics Tested without a database, network or mocks: that is why 181 unit tests run in seconds. A class only for state with a lifecycle: the identity-provider client (metadata, keys), the model client (a connection pool).
Figure 13.3Where logic goes. The access model, the prompt the model is shown and the eval scoring are each one pure function, tested case by case.
RuleWhat it means in practice
A failure has a stable code and a human messageClients, metrics and alerts key on the code (llm_timeout, csrf_failed); the message can change freely. One error shape for every response, with the request id.
Catch where you can handle itA database outage maps to a 503 in one place, not in every route. An unexpected exception becomes a generic 500, logged with its traceback, never returned.
Fail loudly at startupSettings validated with bounds; a missing required secret refuses to start.
Never block the event loop; bound every waitOne synchronous call made the health check take 4.8 s under load. Background tasks start and stop with the application.
Names say what, fullynewest_alerts, require_role; modules named by responsibility, never utils.py.
Comments say whyThe best record the measurement or incident behind the code. A comment that restates the code is deleted, and so is commented-out code: Git remembers it.
Strict typesmypy strict and strict TypeScript; Any only at JSON boundaries; every suppression names its error code.

Coverage, flaky tests and review

Coverage. The api's gate is 85% of lines and branches, and it measures 92.6%. Coverage needs to know about the concurrency library in use: without it, the lines after every awaited database call were reported as never run, and the same tests read 89.7%. A line that ran is not a behavior that was checked.

Flaky tests. Nothing is retried, in pytest or in Playwright. The causes met in practice were all races: asyncio runs a task's done-callbacks one loop iteration after it finishes, so a test asserted too early; two quick cancellations merged into one; and tests shared a per-user rate-limit budget. A model's answers are the one exception, because they vary by design; evals measure a pass rate over repeated runs instead (Handbook 1, Chapter 7).

Review. Formatting and lint are settled by machines in CI, so review is for design and behavior. A good description says what changed and why, how it was verified with the commands and the numbers, one line per new dependency, and what the reviewer should read first. When CI and a laptop disagree, suspect state the laptop has and CI does not: stale images, an old dependency volume, a different user id. CI's user id once exposed a HOME=/ problem the developer's had hidden.

Gates for generated code

When an agent writes most of the code, the failure mode measured across hundreds of repositories is structural rather than functional: more lines, more duplication, higher complexity, more swallowed errors, fewer refactors, while the tests still pass. The answer is not a longer instructions file, which the model weighs against the task, but gates arranged in four layers by how binding they are and how fast they run. The reference build's make check is the command behind the middle layers: an agent runs it before it may say "done", and the pipeline runs it again.

1 · Instructions fileadvice, read each session: commands, the non-obvious, "never edit lint config, CI or the hooks to make a check pass" advisory 2 · Hooks, inside the agent's loop after every edit: format and lint that file (seconds) · before the agent may stop: lint, types, unit tests, the import contract (under a minute) a protected-paths hook denies edits to configuration, pipelines and lockfiles; exit code 2 blocks the edit or the turn deterministic, local 3 · CI on the pull request the full suite · patch coverage · mutation on the diff · complexity, duplication and dead-code ratchets on new code · secret scan · locked installs the one layer the agent cannot edit around; nothing heavy belongs in layer 2 non-bypassable 4 · Branch protectionrequired checks · owners on every guardrail file · one human approval · the committer owns the change
Figure 13.4Gates for agent-written code, by how binding they are. The in-loop hooks keep the agent honest cheaply; CI is the layer that cannot be edited around, so it carries the expensive checks; owners on the configuration files stop a pull request from loosening the rules unnoticed.
What to ratchet on new code, and the tool for each stack
ConcernPython serviceTypeScript app
Invented APIs and signaturesmypy strict, with unused-ignore warnings ontsc strict, unchecked index access, exact optional properties
Complexity and sizeruff's complexity and pylint-style limits (branches, arguments, statements)ESLint complexity, max-lines-per-function, max-depth
Suppression as an escape hatchblanket noqa and unused noqa banned by ruleban-ts-comment; unused disable directives reported
Layeringan import contract: routes call services, services call repositories, handlers never import the ORMdependency-cruiser or boundary rules per folder
Dead code and dependenciesvulture; deptry; installs from the lockfile onlyknip; installs from the lockfile only
Tests that test somethingpatch coverage on the diff against main; mutation testing on the diff, nightlythe same, with a mutation runner for the diff
Duplicationa copy-paste detector with a baseline ref, so only new duplication failsthe same tool, same baseline
Secrets and supply chainsecret scan on every commit; static analysis that exits non-zero on findings; pinned actionsthe same

Two numbers to keep in mind. Coverage is not test quality: one published suite reached 100% line coverage and killed 4% of the mutants introduced into the code it covered, which is why the mutation step sits beside patch coverage rather than instead of it. And generated package names are wrong between one in twenty and one in five times depending on the model, so an install that is not from a lockfile is an install of whatever a typo resolves to. The review that follows the gates is in chapter 18 of the AI handbook: one simplification pass given the diff and the surrounding code, then two reviewers asking different questions, then a person reading the tests first.

Traps

SymptomCauseFix
All tests pass; production crash-loopsTests ran in the dev image, where a dev dependency supplied a missing runtime oneTest the production image itself in CI.
A component test passes before and after a real fixjsdom is not a browserA browser test for anything involving focus, forms or timing.
Coverage drops after moving to an async ORMThe coverage tool loses track at the library's context switchesConfigure coverage for the concurrency library used.
A green eval report while the judge failed every callA missing verdict was treated as a passA missing verdict fails the run.
A green report for a wrong answerThe case's only check looked for one wordRead the answers, not just the scores; strengthen the case.
A redaction function passed every unit test and missed 37 of 54 real secretsEach test used a whole-word label; real logs write DB_PASSWORD=Score it against a corpus of real shapes, held out from tuning.
An insert fails with a policy error after row-level security is addedINSERT … RETURNING is also checked against the read policyAn integration test for every write path.
A test fails one run in twentyA race in the test or the applicationFind it; never add retries.
Every check is green, complexity and duplication keep climbingThe gates test behavior; nothing ratchets structure on new codeComplexity, duplication and dead-code checks against a baseline, in CI; mutation on the diff.
100% coverage, and a planted bug survivesTests execute the code without asserting on itPatch coverage beside mutation testing; read the assertions in review.
A pull request loosened the lint rules to passThe configuration was editable in the same changeOwners on the guardrail files; a hook that denies edits to them in the agent's loop.

Questions

  1. Why does the reference service avoid mocking the database and the model SDK?

    Answer

    A mock tests your assumptions about a dependency. The real failures were in the driver, the pooler and the SDK's behavior, which only real services (or an HTTP server speaking the real API) exercise.

  2. Name two kinds of bug that unit and integration tests cannot find.

    Answer

    Bugs that appear only under load, such as a missing index or a design that collapses at high request rates; and bugs that appear only during a failure, such as connections leaked after a database froze. Rare model behavior is a third, found only over many runs.

  3. Why put decisions in pure functions?

    Answer

    They take and return plain values, so they can be tested exhaustively and fast, with no database, network or mocks; the I/O stays in the routes and clients that call them.

  4. What should a pull request description contain besides what changed?

    Answer

    Why it changed, how it was verified with the commands and the numbers, one line per new dependency, and what the reviewer should read first.

14

Part VIII · Running it

Observability and debugging

Metrics say whether the system is healthy over time, logs say what happened in one request, traces say where one request's time went, and a request id ties all three to the error message a user reports.

At a glance
  • The four golden signals: latency, traffic, errors and saturation. Saturation is the one teams forget, and for async services CPU is a poor measure of it.
  • Percentiles, not averages: p50 is the typical request, p95 and p99 are what the unluckiest users feel.
  • Metric labels must come from small, fixed sets. A raw path or a user id as a label creates a new time series per value.
  • Dashboards and alert rules are code: generated, reviewed, and unit-tested.
  • Alerts describe what users experience, wait out blips, and catch things that vanish entirely, not only things that report zero.
Then and now
ThenNowWhy it changed
Log files on each server, read with grepStructured JSON logs on standard output, collected centrallyContainers come and go; queries need fields, not regular expressions.
Host checks: is the box up, is the disk fullService-level signals: error rate and latency users feelA healthy box can serve a broken service.
Dashboards built by hand in a UIDashboards generated from code and provisioned at startReviewable, reproducible, and never lost with a server.
No view across servicesDistributed tracing with OpenTelemetryOne request now crosses many processes and a model provider.

Three signals, one id

Metricsnumbers aggregated over time,scraped every 15 seconds"Is the system healthy?"Prometheus, Grafana Logsone JSON object per line,on standard output"What happened in this one?"docker logs, jq, a log store Tracesa tree of timed spans perrequest, across processes"Where did its time go?"OpenTelemetry, Jaeger The request idstamped by nginx, or by the api when nothing in front did; kept in a context variable, on every log line, in the response header and every error body, and on the request's span
Figure 14.1The three signals and what each answers. The support workflow follows from the id: a user pastes it from an error message, and the traceback is found in seconds, across the proxy, the api and the model call. An error the TLS edge answers itself, with nginx down, carries the edge's own id, found only in the edge's log.
SCRAPED api workers (/metrics)postgres-exporterpgbouncer-exporterredis-exporternode-exporter (host)cAdvisor (containers)the mock model Prometheusrules, every 15 s Grafanadashboard as code Alertmanagergroups, routes the appa webhook Jaegerapi pushes traces all on loopback; reached through an SSH tunnel on a server
Figure 14.2The reference service's monitoring stack, started beside the application with one command. Alerts are delivered to the application itself, so a demonstration shows its own alerts; a real team adds email, chat or a pager as receivers.

What to measure

SignalReference metricWhy
Traffic and errorshttp_requests_total{method, route, status}The route is the template (/alerts/{alert_id}), never the raw path
Latencyhttp_request_duration_seconds, a histogramPercentiles are computed from buckets; for a stream, the whole stream
Saturationevent_loop_lag_seconds, pool gaugesHow late the loop runs a timer: timeouts appeared at 46% CPU
The modelcalls by outcome, time to first token, tokens by kindTokens times price is cost; outcomes include truncated and cancelled
What runsapp_info{version, prompt, model}When answers change, line the change up with this first
Dependencies, host, containersexporters, node-exporter, cAdvisorDatabase health, disk, memory and out-of-memory kills

Two details of Python servers matter. With several worker processes, a normal metrics client answers each scrape from whichever worker received it: six scrapes after 40 requests returned 15, 15, 5, 6, 14 and 5. In multiprocess mode, each worker writes its samples to files and the endpoint sums them: 40 every time. And every distinct combination of label values is a separate series in Prometheus's memory, so values come only from fixed sets: route templates, outcomes and error codes, never messages, user ids, addresses or a tool name chosen by a model.

In the repoapps/api/app/middleware.pyids that cannot be injected, labels that cannot grow

The middleware that gives every request its id and its route label.

  • An id from upstream is adopted only if it looks like one; anything else is replaced, so a client cannot write into the logs.
  • Metrics and logs use the route's template, so /alerts/424242 is recorded as /alerts/{alert_id}; a test asserts exactly that.
# Accept an upstream id (nginx sets one per request) only if it looks like
# one; anything else is replaced, so clients cannot inject log content.
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,128}$")


def route_template(scope: Scope) -> str:
    """ "/alerts/{alert_id}", never "/alerts/123": raw paths as log or metric
    labels grow without bound (one series per id)."""
    route = scope.get("route")
    return getattr(route, "path", None) or "__unmatched__"

Dashboards and alerts as code

The reference service's Grafana dashboard during a short load: rows for traffic and errors (requests per second, 5xx percentage, rate-limited requests, answers streaming now, requests by route and status, latency percentiles of non-streaming routes, p95 latency by route) and for the model (time to first token, answer duration, model calls by outcome, output tokens per second, an estimated model cost per hour of four cents, canceled answers per minute).
Figure 14.3The top two rows of the reference service's dashboard, during a short burst of traffic. Rows below these cover the dependencies (limiter, pools, PgBouncer, Postgres) and the containers and host; the current dashboard adds one for sign-in and access. Model prices are dashboard variables, so the cost panel follows the provider's price list.

The dashboard is generated by a Python script and committed as JSON, which Grafana loads at start; a check fails if the committed JSON is not what the generator produces, and an edit made in the UI lasts only until the next reload. The first version of the dashboard showed "No data" in four of its thirty panels on a healthy system, for reasons worth knowing: a ratio whose numerator does not exist yet (no 5xx has happened) is "no data", not zero, unless written with or vector(0); rate() needs two samples, so panels blank briefly after a restart, and the very first increment of a new series is invisible to it; and one long-lived streaming route flattened a latency panel until it got its own row.

In the repoinfra/observability/prometheus/alerts.ymlan alert on what users feel

Each alert says what users experience and links to its section in the alert runbook.

  • Deliberate refusals by the assistant's off switch are subtracted, so turning it off does not page anyone; or vector(0) keeps the expression valid before the first refusal.
  • for: 5m: the condition must hold that long, so a blip pages nobody.
  • Another rule uses absent(): a removed service has no series that could be zero.
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[5m]))
            - (sum(rate(chat_refusals_total{reason="assistant_disabled"}[5m])) or vector(0))
          ) / sum(rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels: {severity: high}
        annotations:
          summary: "more than 5% of api requests fail with 5xx"
          runbook_url: "https://github.com/Parzon/triage-assistant/blob/main/docs/runbooks/alerts.md#higherrorrate"
fault scrape ≤15 s for: 1 min, plus evaluation fired: 90 s group wait 30 s notified: about 2 min A fault shorter than a scrape interval can leave no trace at all: two 4-second outages never appeared in the metric. Short failures are found in logs and request errors, not dashboards.
Figure 14.4How long the reference stack takes to notice a failure, measured end to end with the rate limiter's store stopped. Every alert rule also has unit tests that feed it synthetic series and assert when it must and must not fire.

Debugging: which tool for which question

QUESTIONTOOL What happened to this request?the request id in JSON logs, across proxy and api; then its trace What is each worker doing now?py-spy: samples every thread's stack from outside; safe in production Why does this line misbehave?debugpy, the IDE attached over a loopback port; development only It died with exit 139faulthandler: the Python stack of every thread instead of silence Is something blocking the loop?asyncio debug mode in development; the loop-lag metric in production Where does the time or memory go?py-spy flame graphs, cProfile, tracemalloc, -X importtime for startup What did the kernel do?strace (slow); journalctl -k for out-of-memory kills What does the app see on the network?a sidecar in its network namespace: dig, ss, curl, tcpdump What is the database doing?pg_stat_activity, pg_blocking_pids, pg_stat_statements, EXPLAIN ANALYZE
Figure 14.5Debugging tools by layer, from one request down to the kernel. The reference repository's operations guide shows most of them used on the running stack, with their output.

A request that a user complains about is followed from the outside in. The request id finds its lines in the proxy's log and the api's log; if the proxy's total time is much larger than the time it waited for the api, the time went into the client connection, not the service. The trace then shows each step: retrieval, each SQL statement, the model call with its tokens and time to first token. Traces carry ids, counts and timings, never the question or the answer, unless content capture is explicitly turned on in development.

Traps

SymptomCauseFix
Each scrape shows a different, smaller countSeveral worker processes, each answering for itselfThe client's multiprocess mode.
Prometheus memory grows without boundAn unbounded label: raw paths, user ids, free textTemplates and fixed sets only, with a test.
A panel says "No data" on a healthy systemA ratio whose numerator has no series yet(… or vector(0)).
An alert on "service down" never fires when the service is removedIts series disappeared, so it can never equal zeroAn absent() rule.
Timeouts while CPU looks half idleWork queues on the event loopWatch event-loop lag as saturation.
"Trace not found" for most request idsUnsampled requests were handed a trace idOnly return a trace id for a kept trace.
Restarts are slow when the trace backend is downEach worker's shutdown waits for the exporterA short export timeout.
A log query at a given time finds nothingA timestamp without a zone is read as local timeRelative times, or an explicit zone.
An out-of-memory kill is not in Docker's stateThe flag can read false after the main process was killedThe kernel log and the container-level counter.
A breakpoint left in code freezes a worker in productionThe default debugger waits for keyboard inputDisable breakpoints in the production image.

Questions

  1. Which question does each of metrics, logs and traces answer?

    Answer

    Metrics: is the system healthy over time. Logs: what happened in one request or event. Traces: where one request's time went across steps and processes.

  2. Why is CPU a poor saturation signal for an async Python service, and what is better?

    Answer

    Work queues on the event loop, so wall-clock timeouts start firing while CPU is around half used. Event-loop lag, how late a timer fires, measures the queueing directly.

  3. Why can a metric label never be a user id or a raw URL path?

    Answer

    Each distinct combination of label values is a separate time series held in memory. Unbounded values create unbounded series; labels must come from small, fixed sets such as route templates and error codes.

  4. Roughly how long does the reference stack take from a failure to a notification, and why?

    Answer

    About two minutes: up to a scrape interval, the rule's one-minute for, evaluation, and Alertmanager's grouping wait. A fault shorter than a scrape can leave no trace in metrics at all.

15

Part VIII · Running it

Reliability: timeouts, failures and drills

A reliable service decides in advance what happens when each dependency fails, bounds every wait where the work actually happens, answers in seconds rather than hanging, and proves all of it by breaking each dependency on purpose.

At a glance
  • Every soft dependency has a written failure policy: fail open, fail closed, or a choice per endpoint.
  • Cap work on the server that does it. A client-side timeout only stops waiting, and its cleanup can hang.
  • Interactive traffic fails fast: seconds, a clear 503 and a request id, not a two-minute queue built for batch jobs.
  • Liveness checks nothing external; readiness checks hard dependencies and only reports soft ones.
  • Failure drills inject one fault at a time into a production-shaped stack. Freezing a dependency is harsher than stopping it, and the worst bug appeared only under load.
Then and now
ThenNowWhy it changed
Failure behavior discovered during the outageA matrix of faults and what users see, measured before launchEvery service fails; the question is how.
Default timeouts from libraries and proxiesEvery wait bounded, with the sum kept under the outer limitDefaults suit batch work: PgBouncer waits two minutes for a connection.
"Is it up?" health checksLiveness and readiness as two different questionsA database outage must not restart every replica at once.
Disaster recovery tests once a yearSmall, repeatable drills run after every risky upgradeCheap containers make faults easy to inject.

Decide what happens when a dependency fails

Fail openallow the requestavailability wins; no limitfor the outagewhen the limit is a courtesyand the store is optional Fail closedreject with a 503protection wins; a storeoutage is a full outagewhen the limit is the control:sign-in attempts, abuse Per endpointclosed where it is expensive,open on cheap readsthe reference service: open,200 ms budget; the chat failsclosed in production, becauseeach request is a paid model call Whichever you choose:bound the wait, count each fail-open or fail-closed decision, alert on them, and write the choice down as a decision record.
Figure 15.1Three failure policies, shown for a rate limiter. The same question applies to every soft dependency: a cache, a feature-flag service, an identity provider's metadata.

Where timeouts belong

Put the cap where the work happens: the database's statement_timeout, the pooler's query timeout. The server then stops the work and returns a clean error. A client-side timeout only stops waiting, and if its cleanup needs the remote side to cooperate, a dead remote turns that cleanup into a hang. That was the reference service's worst bug.

BEFORE: A CLIENT-SIDE TIMEOUT query times outon the client driver asks the serverto cancel rollback waits for theacknowledgment server frozen: it nevercomes; held forever Postgres frozen 20 s under load: 13 of 40 pool connections stuck after it recovered. AFTER: CAPPED WHERE THE WORK HAPPENS query time capped only on the server: the role's 10 s statement timeout, the pooler's 15 s backstop the pooler fails fast: 5 s queue, 5 s connect, 2 s login retry, 1 s for a cached "no such host" a connection closed mid-query maps to a 503; the readiness probe abandons a slow check rather than canceling it The same drill: 67,121 reads served, 28 clean 503s, 0 connections held afterwards.
Figure 15.2The leak the drills found, and the fix. Single-request drills never showed it; only a freeze under load, followed by a check that the pools were empty, did. The test pattern is worth copying.
In the repocompose.yamlthe pooler, made to fail fast

PgBouncer's defaults suit batch jobs. Each setting here records its default and the drill that showed why it had to change.

  • A client waited two minutes for a connection by default; interactive requests now get an error in five seconds.
  • The query timeout is a backstop above the database role's own ten-second limit, for when Postgres cannot enforce it itself.
  • A stopped container vanishes from Docker's DNS, and the pooler cached that answer: Postgres was back while every login still failed.
      # Fail fast when Postgres is down or frozen (make drills d="db-stop
      # db-hang"). PgBouncer defaults in brackets.
      # A client waits this long for a server connection, then gets an error [120s].
      QUERY_WAIT_TIMEOUT: 5
      # Cancels a query running this long and closes its connections [off].
      # A backstop for when Postgres cannot enforce the role's 10s
      # statement_timeout itself (frozen, partitioned): keep it above 10s.
      QUERY_TIMEOUT: 15
      # A new server connection must be up within this [15s].
      SERVER_CONNECT_TIMEOUT: 5
      # After a failed login to Postgres, retry after this long [15s] - which
      # is also how long clients keep getting "server login has been
      # failing" once Postgres is back.
      SERVER_LOGIN_RETRY: 2
      # A stopped container vanishes from Docker's DNS, and PgBouncer caches
      # that "no such host" answer [15s]: Postgres would be back while
      # PgBouncer still failed every login from its cache.
      DNS_NXDOMAIN_TTL: 1

Three more rules about waiting

  • Cancel or abandon. A timeout cancels once, and cleanup after it can block again. To answer on time no matter what, run the work as a separate task, wait with a deadline, and abandon it (keep a reference, let it finish) when canceling would trigger a cleanup that can hang.
  • Propagate deadlines. Pass the remaining budget down with each call (gRPC deadlines, a per-transaction statement timeout), so no layer works longer than its caller will wait. Otherwise a timed-out client leaves work running, and a retried write can commit twice: hence idempotency keys on writes.
  • Keep the sum under the outer limit. The longest a user can wait is the sum of the timeouts along the path; the proxy's must be above it (Chapter 11).

Liveness and readiness

Liveness: /health"Is the process alive?" checks nothing externalfailing it restarts the container, and adatabase outage must not restart everyreplica at oncecontainer and load balancer health checks Readiness: /ready"Should traffic come here?" fails on hard dependencies: the databasereports soft ones as degraded, still 200:the limiter's store, the identity providerotherwise a partial outage becomes totalorchestrator routing decisions, dashboards
Figure 15.3Two different questions. ECS has no readiness-only signal: its container health check and the load balancer's target health both lead to a task's replacement. Pointed at readiness, a database outage marked every api task unhealthy and ECS kept replacing them, so on ECS both point at /health (Handbook 3, Chapter 9).

Failure drills

The reference service's drill script injects one fault at a time into the production-shaped stack with plain Docker: stop for "down", pause for "frozen" (the process is alive, its connections stay open, nothing answers), a network disconnect, a kill from the host for a crash, a lowered memory limit, and the mock model's admin interface for provider failures. It records what a signed-in user sees on each path, restores the stack, and checks recovery. An injection that fails is reported as a failure, never as a result: an early version reported "rate-limited model, answer fine" because the mock was still starting.

FAULTWHAT USERS SEEVERDICT Valkey stoppedreads, writes and sign-in work, unlimited; the chat refuses in 14 msdegraded PgBouncer frozena JSON 503 after 5.0 sfails fast Postgres stoppeda JSON 503 in 15 ms; nobody is signed in without itfails fast The model hangsheartbeats keep the stream open; llm_timeout at 60 sbounded Identity provider downsigned-in users notice nothing; new sign-ins faildegraded One worker killedits streams cut (3 of 6); everything else unaffectedcontained Recreate deploystreams finish; new requests get 502 for 6.7 sdowntime Rolling deployno failed request in three runs through the edgeinvisible Memory below working seta crash loop; the site is down until the limit is fixedoutage
Figure 15.4Nine of the reference service's twenty-odd drills, as measured after the fixes. The full matrix in the operations guide also names the alert that covers each fault if it lasts, and how long recovery took.

Two findings shape deployment. A single container replaced by "stop, then start" refuses new requests for as long as the longest in-flight answer takes to drain, plus boot time; a rolling deploy starts the new instance beside the old one and drains the old one only once the new one is ready (Handbook 3, Chapter 13). And memory limits on a host with swap were soft: the api put 48 MiB in swap and kept serving without a single error, until every limited service set its swap limit equal to its memory limit, which made an out-of-memory kill loud and alerting.

One failure appeared once and would not come back. During a full drill run the api's pool stayed at 40 of 40 connections while the database was healthy, and every request got a 503 until the api was restarted. Repeating the faults that were active changed nothing; the variable nobody had changed was the api's address. When a container rejoins a network, Docker usually gives it another address, and every pooled connection stays bound to the old one. Data sent on such a connection is never acknowledged, the kernel retransmits for about 15 minutes, and the pool's health check, which has no timeout of its own, hangs at each one. The fix is a socket option, TCP_USER_TIMEOUT, which drops a connection whose data goes unacknowledged for 10 seconds; it never fires for a slow or frozen database, whose kernel still acknowledges. A new drill fills the pool and brings the api back on another address: ready again in 50 seconds, instead of never (ADR-0027 in the repository).

Traps

SymptomCauseFix
Pool connections stay in use after the database recoversA client-side cancel whose cleanup waits for a dead serverCap time on the server; drill a freeze under load and check the pools.
Requests hang for two minutes when the database is downThe pooler's queue timeout, designed for batch workSeconds, with a clear 503.
The pool stays full after a network fault, with the database healthyPooled connections are bound to an address the container lost; nothing acknowledges them, and the kernel retransmits for minutesDrop unacknowledged connections (TCP_USER_TIMEOUT); drill a fault that changes the address.
A database blip restarts every replicaThe liveness check tests the databaseLiveness checks nothing external.
A cache outage takes the whole site downReadiness fails on a soft dependencyReport it as degraded; keep answering 200.
The drill says a dependency failure was harmlessThe fault was never injectedVerify each injection; report a failed one as such.
A crashed container never restarts in a drilldocker kill counts as a manual stopKill the process from the host, as the kernel would.
A short outage leaves no trace in metricsIt was shorter than a scrape intervalLook in logs and request errors.
A retried write is applied twiceThe first attempt kept running after its caller timed outDeadline propagation and idempotency keys.

Questions

  1. When should a rate limiter fail open, and when closed?

    Answer

    Open when the limit is a courtesy on cheap requests and its store is a soft dependency; closed when the limit is itself the security control, or when it guards spend because each request is expensive, as with a paid model call.

  2. Why is a client-side timeout not enough to protect a pool?

    Answer

    It stops waiting, not the work. If the driver's cleanup needs the server's cooperation, a frozen server turns the cleanup into a hang and the connection is held forever. A server-side cap stops the work itself.

  3. What is the difference between liveness and readiness?

    Answer

    Liveness asks whether the process is alive and checks nothing external, because failing it restarts the container. Readiness asks whether traffic should come here: it fails on hard dependencies and reports soft ones as degraded.

  4. Why is freezing a dependency a harsher drill than stopping it?

    Answer

    A stopped dependency refuses connections at once. A frozen one keeps its connections open and never answers, so only timeouts protect you, and code paths that wait without a bound hang.

16

Part VIII · Running it

Performance and load testing

A load test answers a question with a number in it, on production-sized data, with load that keeps arriving whether the server keeps up or not; and the bottlenecks it finds arrive in a predictable order, starting long before the servers look busy.

At a glance
  • Start from a target: "500 reads per second at p95 under 50 ms", not "is it fast?".
  • Use an open model (a fixed arrival rate) to ask about throughput; a closed model (a fixed number of users) hides latency through coordinated omission.
  • The shape of arrivals matters as much as the rate: the same 100 requests a second, clumped, was nearly twenty times slower.
  • Watch the four signals together, and the load generator's own CPU.
  • Change one thing at a time, and keep the before and after numbers in the pull request.
Then and now
ThenNowWhy it changed
JMeter plans in XML, built in a GUILoad scripts as code (k6, Locust), versioned beside the serviceReviewed like code and run by anyone with one command.
Average response timePercentiles under a fixed arrival rateAverages hide the tail that users feel.
A performance phase before launchA small, repeatable investigation before a risky changeBottlenecks move with every feature; sign-in alone doubled the cost of the cheapest request.
Load tests on a shared CI runnerLoad tests on a dedicated, quiet environment, on demandA shared runner's speed varies, so a latency gate there is either flaky or toothless.

Open and closed load

Closed: N users, each waits the server stalls → users stop sending stall: none sent the slow period's requests are never sent,so their latency is never recorded coordinated omission: the percentiles look fine Open: a fixed arrival rate requests keep arriving, like real users a stall builds a queue, and the queueshows as latency, honestly right for "can we serve X per second?" Measured: at a fixed 20 iterations per second, k6 had to grow from 16 to 157 concurrent users to hold the rate, and exposed a 7-second p95 (a missing index, Chapter 6). A closed test with 16 users would have offered less load and reported something far milder. A closed model is still right for concurrency questions: "how many streams can one worker hold?"
Figure 16.1Two load models. Before trusting any tool's numbers, check which model it uses, its timer resolution, and its own CPU at the rate you need: one tool needed 534% CPU to offer 200 requests a second, where k6 needed 5%.
In the repotests/load/k6/alerts-read.jsan open model, with pass and fail thresholds

A k6 script: JavaScript run by a Go engine. The executor names the load model, and thresholds turn a run into a pass or a fail.

  • constant-arrival-rate starts iterations at a fixed rate; maxVUs caps how many may be in flight while the server catches up.
  • Each request is tagged, so each query gets its own p95 threshold.
  • The script runs signed in, through nginx, as users arrive; rate limits are raised for the run, or the test measures the limiter's 429s.
export const options = {
  scenarios: {
    // Open model: iterations start at a fixed rate whatever the response
    // time, like independent users. A closed model (a fixed number of VUs
    // looping) slows its own arrival rate when the server slows down, and
    // hides the very latency it is supposed to measure ("coordinated
    // omission"). maxVUs caps how many can be in flight at once.
    reads: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.RATE || 50),
      timeUnit: '1s',
      duration: __ENV.DURATION || '60s',
      preAllocatedVUs: 50,
      maxVUs: 1000,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    'http_req_duration{kind:critical}': ['p(95)<200'],
    'http_req_duration{kind:recent}': ['p(95)<200'],
  },
  summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'],
}
Same rate: 100 requests per second spread evenlyp50 1.5 ms in clumpsp50 29 ms After sign-in doubled each request's cost 200 users, spread3 ms 200 users, in stepp50 182 ms On one event loop, the last request of a burst waits for all the others. Each request's CPU cost multiplies the queue a burst builds. Know which shape your real traffic has.
Figure 16.2Arrival shape changes the answer at the same average rate. Bars are drawn to scale within each panel.

How much one service served

The reference service was measured on one box: the production-shaped stack with two api workers on two CPUs, two million seeded alerts, and load generated through nginx by k6 on the same host. Because the generator competes for the same CPUs, the absolute numbers are that box's; the ratios are the lesson.

READS: OFFERED → ACHIEVED 500 / s500 · p95 under 2 ms · 0.5 core 1,000 / s1,000 · p95 173 ms · 1 core 2,000 / s1,710 · p95 1.78 s the api at its 2-core limit while Postgres used under one core: the ceiling is api CPU, not the database STREAMING ANSWERS (MOCK MODEL) 50 streams: first event p95 114 ms, 37 answers/s · 500 streams after the fixes: 1.25 s, 239 answers/s, 0 failed, 2 cores saturated
Figure 16.3Capacity of one two-CPU instance, before sign-in existed, from the operations guide's capacity run. A later A/B run of the same read measured p95 2.6 ms at 1,000 a second before sign-in and 19.7 ms signed in; signed in, the cheapest read costs about twice the CPU: roughly 530 reads per second per core instead of 920. For a real model, the provider's tokens-per-minute quota binds long before the api's CPU does.

The bottlenecks, in the order they bite

1A missing index: the first bottleneck arrives before any real load does (Chapter 6) 2Connection churn: a slow state that sustains itself; a fixed-size pool ended it (Chapter 7) 3api CPU on reads: about 530 signed-in reads per second per core 4api CPU on streaming: the SDK's 126 µs per chunk; 500 streams saturate two CPUs 5Event-loop saturation becomes errors elsewhere: timeouts expire while replies wait in the queue, from 46% CPU 6Connection budgets: 2 workers × 20 = 40 pooler clients per replica; the pooler's 500 allows about 12 replicas 7The model provider's quota: with a real provider, this binds first; plan capacity from it
Figure 16.4The reference service's bottlenecks, in the order load found them. Each has a before-and-after measurement in the operations guide; the settings that came out of them are one decision record.

A saturation cascade

At 200 to 500 concurrent streams, errors came from everywhere at once: connection resets in nginx, thousands of rate-limiter fail-opens, Redis pool exhaustion and database pool timeouts. Each had its own cause, and they amplified each other. Workers were being recycled every ten thousand requests, closing keep-alive connections nginx was about to reuse, which nginx does not retry for a POST; the limiter's 50-millisecond budget was wall-clock time, so it included the time a busy loop took to read the reply; the Redis pool was smaller than the number of concurrent requests; and the database pool was churning. After four changes: zero errors in 10,056 answers, and more answers per second. The general lesson is that on a busy event loop wall-clock timeouts expire while finished replies wait to be read, so the first symptoms of saturation are fail-opens and pool timeouts at moderate CPU, not slow responses.

SHARE OF SAMPLES AT 300 STREAMS provider SDK 40.1% HTTP client 21.9% asyncio 18.2% pydantic 5.0 · framework 4.5 · our code 2.5 · the rest Per streamed chunk: the SDK's client side costs 126 µs, the raw HTTP client plus JSON parsing 55 µs. 12,500 chunks a second × 126 µs ≈ 1.6 cores, which matches the saturation. The SDK was kept: about twice the streams per core is not worth owning the provider's protocol, and a real provider's quota binds first.
Figure 16.5A sampling profile, summarized. In a flame graph, each box's width is its share of samples, and height is only call depth: look for wide plateaus near the top. The service's own code was 2.5% of the time.

The method, as a checklist

  • A target with a number, and seeded data at production volume: an idle EXPLAIN said 43 ms for a query that took seconds under load.
  • An open-model load, a warm-up, then a steady state long enough for the p99 to settle.
  • The dashboard open: rate, errors, p95 and p99, loop lag, pools, CPU per container; and the load generator's own CPU.
  • Database statistics reset before the run and read after; a profile during the steady state if the api's CPU is the limit.
  • One change at a time; before and after numbers in the pull request; a decision record if it changes a default.

Traps

SymptomCauseFix
The load test looks fine; production is slowA closed model offered less load as the server slowedA fixed arrival rate.
A fast query in EXPLAIN is slow under loadForty concurrent full scans are a different workload from oneProfile under realistic load and data volume.
Latency climbs with the generator's CPUA saturated generator measures its own queueingWatch its CPU; use an efficient tool or more machines.
The test measures 429sRate limits apply to the test userRaise them for the run.
Errors appear at half CPUWall-clock budgets include event-loop queueingWatch loop lag; size budgets for a busy loop.
Periodic 502 bursts under loadWorker recycling closed connections the proxy was reusingRecycle only to contain a measured leak.
A memory-leak hunt reports a leak that is not thereObjects in reference cycles look allocated until collectedCollect before each snapshot; compare two run lengths, since a leak grows with N and a cache does not.
A latency gate in CI is flakyShared runners vary from run to runGate performance on a dedicated, quiet environment, if at all.

Questions

  1. What is coordinated omission, and how does an open model avoid it?

    Answer

    In a closed model, a stalled request delays the requests that would have been sent meanwhile, so their latencies are never recorded and the percentiles look better than reality. An open model sends at a fixed rate regardless, so the stall shows up as queueing latency.

  2. Why can the same average request rate give very different latencies?

    Answer

    Arrival shape: clumped arrivals queue behind each other on one event loop, so the last request of a burst waits for the rest, even though the average rate is the same.

  3. In the reference service, what was the capacity ceiling for reads, and what does that imply?

    Answer

    The api's CPU, not the database, which used under one core at the limit. More capacity means more CPUs or replicas; a faster database would not help.

  4. Why were the first signs of saturation errors rather than slow responses?

    Answer

    On a busy event loop, a timeout measured in wall-clock time can expire although the reply has arrived, because it includes the time the reply spends waiting for the loop. Limiter fail-opens and pool timeouts appeared at moderate CPU.

17

Part VIII · Running it

Incidents, runbooks and service levels

Service level objectives turn "is it working?" into numbers with a budget, runbooks turn an alert into a first action, an off switch turns the riskiest feature into something one person can stop in seconds, and every incident ends in a change that makes the next one shorter.

At a glance
  • An SLO is a target on a measured signal users feel, over a window; the error budget is what is left to spend.
  • While budget remains, ship; when it is spent, reliability work comes first. Agree that with the product owner before the first incident.
  • Every alert links to a runbook section: what users see, what to check first, what caused it before.
  • Severity is decided by user impact, not by cause. One person leads; a timeline is written as you go.
  • Blameless reviews end in a drill, an alert or a runbook fix, each with an owner.
Then and now
ThenNowWhy it changed
"Five nines" in a contract, measured by nobodySLOs on real signals, with an error budget the team spends deliberatelyA number nobody measures cannot guide a decision.
Threshold alerts on hostsBurn-rate alerts on the SLOPage on what users feel, and not on blips.
A wiki of procedures, out of dateA runbook section per alert, linked from the alert itself, fixed the same day it failsThe runbook is read at 3 a.m.; it has to be right and one click away.
Post-mortems that find who to blameBlameless reviews that find why the system allowed itPeople hide what they will be blamed for; systems can be fixed.
Disable a feature by deployingA switch that takes effect in seconds, on every serverAn AI feature can start doing harm without any code change.

Service levels and error budgets

SLOMeasured asProposed target, 30 days
Availabilitythe share of api requests not answered 5xx99.5%: a budget of 3.6 hours of errors a month
Read latencyp95 of the alert list's request durationunder 300 ms
Time to first wordp95 of the model's time to first tokenunder 3 s
Answers completedmodel calls ending ok or truncated, among those not canceled99%
Answer qualitythe eval suite on the production model, per releasethe gate passes; safety cases 100%
BUDGET REMAINING OVER 30 DAYS 100%0 spent exactly on plan: burn rate 1 fast burn: 14.4× over 1 hour, and 5 min2% of the month in an hour: page Budget left: ship features. Budget spent: reliability work first, until it recovers. Slow burn: 1× over 3 days opens a ticket, not a page. A deliberate "off" is not an outage: switched-off refusals are left out of the availability SLO.
Figure 17.1An error budget and multi-window burn-rate alerts. The reference service still uses threshold alerts (5% errors for 5 minutes); its production guide proposes these targets and the burn-rate alerts that replace thresholds once the targets are agreed.

Runbooks

The reference repository has three kinds of runbook. The alert runbook has one section per alert rule, and each rule's runbook_url links straight to it. An operational runbook covers a procedure, such as setting up and running a demo host. A situation runbook covers a decision under pressure, such as turning the assistant off. Every section answers three questions in the same order, because the reader is under stress and needs the first action first.

In the repodocs/runbooks/alerts.mdone alert's section

The section an alert links to: short, specific, and built from what happened before.

  • Users: what they experience, which sets the urgency.
  • Check: the first two things to look at, with the exact panel and command.
  • Seen before: past causes, each pointing to its own alert or fix. This list grows after every incident and game day.
## HighErrorRate

**Users:** more than 5% of requests fail with 5xx.
**Check:** the dashboard's "Requests / s by route and status" (which
route, which status), then `make trace id=<id>` on one failing request.
**Seen before:**
- 503 `database_unavailable`: see PostgresDown / PgBouncerDown.
- 502/504 `upstream_unavailable` from nginx: the api is down or stuck
  (ApiDown, EventLoopLagHigh).
- Bursts of 502 on POSTs under load came from gunicorn worker recycling
  (now off).

An off switch for the riskiest feature

Whenharmful advicea provider breach orchanged termsa runaway bill Switch off, with a reasonan org admin, in the app; or anoperator on the host when sign-initself is downseconds, every server, no deploy What users seequestions: 503 with the reasonalerts and runbooks: workingrefusals counted, not paged:left out of the error-rate alert While it is offthe audit trail shows what the model was given for each question,by hash, and who wrote each runbook section it saw Back onafter the cause is fixed andthe evals pass; both flips audited
Figure 17.2The reference service's situation runbook for its AI feature. When in doubt, turn it off first and investigate after: nothing else in the service depends on it.

Incidents and game days

Detectan alert, or aperson noticed Declareseverity by impact;one incident lead Mitigateroll back, switch off,fail over; fix later Reviewblameless, withina week Changedrill, alert,runbook line each review makes the next detection faster SEV1 nobody can use it · SEV2 a core flow is broken for some · SEV3 degraded, with a workaround The timeline is written as you go: request ids, dashboards and, for a harmful answer, the audit trail make it reconstructible.
Figure 17.3The incident loop. In a small team one person may be both incident lead and fixer; say so out loud. Most reviews should end in a new drill, an alert, or a runbook line, each tracked as an issue with an owner.

Drills inject faults into a stack nobody depends on; a game day adds the people. One person injects a scenario while the on-call engineer responds without knowing it in advance, and the team records the time to detect (did an alert fire, or did someone notice?), the time to mitigate, what users saw, and what the runbook got wrong, then fixes the runbook the same day. Scenarios worth rehearsing for a service like the reference one: the host is gone and must be restored from off-host backups; the identity provider is down; the model provider is down or rate limiting; the assistant gives harmful answers; the disk fills; a bad release is rolled back under load; and a leaked credential is rotated to see what breaks.

Backups are only real once restored

Only Postgres needs backing up: the rate limiter's counters are disposable, and runbook vectors can be rebuilt from runbook text, but the text cannot. Backups are copied off the host, because a dump on a server's disk dies with the server, and a restore is rehearsed at production size before it is trusted. A restore also signs out everyone who signed in after the dump, since sessions live in the database.

The honest list

The reference service's production guide ends with what has never been tested, and why each matters: certificate renewal over months, the organization's real identity provider, a hosted model's latency and limits, browsers other than Chromium, more than one host, real traffic, a full-size restore, months of operation, a third-party security review, an accessibility audit, and answer quality on real questions. Each needs its own run before the stage that depends on it. A list like this is what makes the rest of a production guide credible.

Traps

SymptomCauseFix
People are paged for blipsThreshold alerts without a duration, or on causes rather than symptomsBurn-rate alerts on the SLO, with a short and a long window.
Switching a feature off pages the on-call engineerDeliberate refusals counted as errorsCount them separately and leave them out of the error alert and the SLO.
An error budget is spent and nothing changesThe policy was never agreed with the product ownerAgree it before the first incident.
The runbook is wrong during the incidentIt was written once and never exercisedGame days, and fixing the runbook the same day.
The off switch cannot be used during the outageIt needs the app's sign-in, which is downA second path from the host, documented in the runbook.
A restore takes far longer than expectedIt was rehearsed only on small dumpsTime a full-size restore.
The review produced a document and no changeAction items without ownersIssues with owners: a drill, an alert or a runbook line.

Questions

  1. What is an error budget, and how does it change what a team works on?

    Answer

    The amount of failure the SLO allows over its window, such as 3.6 hours of errors a month at 99.5%. While budget remains, the team ships; when it is spent, reliability work comes first until it recovers.

  2. Why are burn-rate alerts better than a fixed error threshold?

    Answer

    They page on how fast the budget users care about is being spent, with a short window for fast burns and a long one for slow leaks, so blips do not page and slow degradations still get noticed.

  3. What three things does each alert's runbook section answer?

    Answer

    What users experience, what to check first (exact panel and command), and what caused it before.

  4. Why does an AI feature deserve an off switch rather than a rollback?

    Answer

    It can start doing harm without any code change, through a poisoned document or a change at the provider, so there may be nothing to roll back; and a switch takes effect in seconds on every server.

18

Part IX · Choosing and starting

The stack catalog

A useful stack catalog does not list versions; for each technology it says what job it does, whether a new service starts with it, and the named trigger that justifies adding it, because every piece beyond the smallest production-shaped service has a price.

At a glance
  • Two marks per technology: how well an engineer should know it, and when a new project adopts it (day one, when a trigger fires, or rarely).
  • Versions live in lockfiles and image lines, moved by a bot; a version table in a document goes stale within weeks.
  • A piece is added for a measurement, a written requirement, or a failure users would see. The reason goes in the pull request or a decision record.
  • Price a piece before adding it: an image to patch, settings and secrets, a failure mode, backups, upgrades, monitoring and reading time.
  • Take a piece away when nobody can say how it fails, a managed service does the job, or it was added "in case".
Then and now
ThenNowWhy it changed
An architecture diagram with every component from the startThe smallest production-shaped service, then pieces added on named triggersEach piece is a failure mode to run; most were never needed.
A separate database per concern (search, vectors, cache)Postgres first, with extensions, until a measurement says otherwiseOne system to back up, secure and reason about.
Self-hosted everythingManaged equivalents once in the cloudThe provider runs the failure modes for you.
Redis, Docker Desktop and others chosen without a license checkLicense, maintenance and architecture checked before adoptionSeveral popular tools changed their licenses.

The map

day one add when rarely AI OpenAI-style seam eval harness mock model pgvector + FTS Ollama MCP agent mode API Python + uv FastAPI SQLAlchemy Uvicorn in gunicorn /metrics, logs Data Postgres row-level security PgBouncer Valkey a vector database Web React + TypeScript Vite TanStack Query fetch streaming nginx Sign-in, edge OIDC with PKCE Keycloak, locally Caddy Delivery Docker, Compose GitHub Actions Make registry Dependabot cloud-init Kubernetes Supply chain Trivy gitleaks Quality pytest ruff and mypy Vitest Playwright k6 Monitoring Prometheus Grafana Alertmanager Jaeger Keycloak runs in development only: in production, the organization's own identity provider replaces it.
Figure 18.1The reference stack by layer. Everything in the first color is what a new service starts with; everything in the second waits for its trigger (Figure 18.2); Kubernetes and a separate vector database need a reason you can name.

Triggers for adding a piece

AddWhen
pgvector and full-text searchThe answers must come from your documents
Valkey (a shared store)More than one process shares a limit, a cache or a lock; a counter in each worker's memory gives a user N times the limit
PgBouncer, or RDS ProxyReplicas × workers × pool size nears Postgres's connection limit, or new connections churn
An edge proxy for TLSA VM serves HTTPS itself, with no load balancer in front
Tracing and a trace storeSomeone asks what one answer saw and where its time went; with retrieval, that is early
Prometheus, Alertmanager, GrafanaSomeone is on call
A local model serverEvals need a real model without a paid key, or data must not leave the machine
An MCP server; agent modeOther AI clients should use the tools; the steps are not known before the model runs
Browser tests; load testsThe UI has a flow a unit test cannot prove; a capacity question has a number in it
ADD FOR A TRIGGER YOU CAN NAME a measurementa number, and the command behind it a requirementwritten: sign-in, an SLO, compliance a failure users would seeshown by a drill Then price it an image to patch and scansettings and secretsa failure mode to decide and drilla backup, if it holds statean upgrade pathan exporter, alerts, a runbooka decision record, reading time Take it away when nobody can say how it failsa managed service does the jobtwo things do one jobit was added "in case", andnothing has needed it since The price of one small piece: Valkey, for rate-limit counters alone, cost a fail-open decision, a fail-closed exception for the chat, three alerts, two drills, an exporter and two sizing fixes under load.
Figure 18.2The rules for adding and removing architecture. On the same rule, the reference repository later removed five load-testing tools, a deep-debugging kit, two host rehearsals and three document types; Git history keeps them.
In the repoTECH_STACK.mdone entry in the catalog

Each entry gives the two marks, the job, the trigger and what to watch. This one shows the format for a piece that is not day one.

  • The heading line: the name, how well to know it (half-filled: explain and review), and the trigger.
  • The classic bug it prevents: a counter in each worker's memory gives a user N times the limit.
  • The failure policy, with its decision records, and the configuration detail that matters (an eviction policy that suits counters, not data you must keep).
**Valkey** ◐ · when more than one process shares a limit, a cache or a
lock<br>
Rate-limit counters shared across workers. A counter kept in each
worker's memory is a classic bug: with N workers, a user gets N times the
limit. Valkey is the open-licence fork of Redis and speaks the same
protocol (ADR-0002). Decide what happens when it is down: here limits
fail open (ADR-0004), except the chat's, which fails closed in
production because each question costs money (ADR-0023). Its
`allkeys-lru` eviction suits counters, not data you must keep.

The same service at three sizes

LAYER Week one One VM (the repository) On AWS Computeapi and web in Composegunicorn, rolling deploysECS on Fargate, an ALB DataPostgres (+ pgvector)+ PgBouncer, off-host backupsRDS or Aurora; RDS Proxy Shared statenoneValkeyElastiCache for Valkey EdgelocalhostCaddy, then nginxALB with ACM; CloudFront Sign-inKeycloak locallythe organization's providerthe organization's provider Modelthe mock, one providermock, Ollama, a providerBedrock or a provider WatchingJSON logs, /metricsPrometheus, Grafana, Jaegermanaged Prometheus, orCloudWatch; a collector DeliveryCI with tests and scans+ releases, a deploy script+ ECR, infrastructure as code
Figure 18.3One service, three sizes. The code and the seams stay the same; what changes is who runs each failure mode. Handbook 3 walks through the right-hand column, piece by piece.

Adding a technology

  1. Justify it in one line in the pull request: what it does that the stack cannot.
  2. Check its license, that it is maintained, its security record, that it publishes builds for both CPU architectures, and its size.
  3. Pin it in a lockfile, or by image tag and digest, so the update bot and the scanners cover it.
  4. Price it, and write a decision record if a reader would ask "why this?".
  5. Add it to the catalog with its trigger, and add a line to the traps list when it first bites.

Traps

SymptomCauseFix
Each user gets several times their rate limitCounters kept in each worker's memoryA shared store once there is more than one process.
A license change forces an urgent migrationThe license was never checked or watchedCheck before adopting; prefer open-license equivalents.
The team cannot explain how a component failsIt was added "in case"Remove it, or decide and drill its failure mode.
Builds fail on ARM laptops or cloud instancesA dependency publishes only x86 buildsCheck for both architectures before adopting.
The document says one version, production runs anotherA version table in proseLockfiles and image lines are the truth.

Questions

  1. What are the three kinds of trigger that justify adding a component?

    Answer

    A measurement with the command that produced it, a written requirement, or a failure users would see, shown by a drill.

  2. Name four costs of adding even a small component.

    Answer

    Any four of: an image to patch and scan, settings and secrets, a failure mode to decide and drill, a backup if it holds state, an upgrade path, monitoring with alerts and a runbook section, a decision record and every new engineer's reading time.

  3. When should a component be removed?

    Answer

    When nobody can say what happens when it fails, when a managed service does the same job, when two things do one job, or when it was added "in case" and nothing has needed it since.

19

Part IX · Choosing and starting

Starting a new service

A new service starts with its pipeline, not its features: a walking skeleton deployed end to end before the first feature, the platform copied from a template that already works, and only the domain, the AI part and the configuration written fresh.

At a glance
  • Before any feature: the repository, CI on an empty project, a secrets strategy, branch protection, and a health endpoint deployed by the pipeline.
  • A new engineer goes from git clone to a running stack in under an hour, ideally with one command.
  • From a template: keep the platform, replace the domain, configure the providers.
  • Replace the domain in order: requirements, data model with its access rules, routes, eval cases before the prompt, then the UI.
  • Some rules stop copies drifting into different platforms; change them only with a decision record.
Then and now
ThenNowWhy it changed
Features first; deployment worked out near launchA walking skeleton in the real cloud in the first two weeksCloud access, IAM, networking, TLS and DNS always hold surprises.
Every project laid out differentlyOne shape for every project, from a templateWhoever inherits project seven already knows where everything is.
A shared development databaseA database per developer in containers, seeded; an ephemeral one per pull request in CIIsolation and reproducibility are cheap now.
A copy of production data for debuggingSynthetic or anonymized data, in staging, never on laptopsPersonal data on laptops is a breach waiting to happen.

Before the first feature

1repository, .gitignore, a READMEsaying what and why 2a monorepo, for a small teamone PR spans api, UI and infra 3lint, format, and CI running onan empty project 4.env.example and a secretsstrategy, decided first 5branch protection before thefirst pull request 6boring, reversible defaults;no novelty budget spent here 7a health endpoint, built by CIand deployed by CD: the skeleton 8structured logs and an uptimecheck, from day one 9a decision record for eachirreversible call RULES THAT GENERALIZE The pipeline exists before feature one. Secrets never reach Git from the first commit: retrofitting means rewriting history. A new engineer runs the stack within an hour of cloning. Prefer decisions that are cheap to reverse. Request slow approvals in week one: cloud access, security review, data-processing terms, named pilot users.
Figure 19.1The order of operations before feature code. Step seven matters most: once a health endpoint reaches the real environment through the pipeline, every later feature travels a path that already works.

Every project a team builds should look the same outside its domain logic, so that whoever inherits it, often an infrastructure team taking a proven service to scale, knows where everything is with no ramp-up. The reference repository's first decision record set that shape: the api and web apps in fixed places, the AI logic in its own clearly named module, infrastructure in its own directory, decisions as numbered records, the same CI shape, and configuration from environment variables so the secrets source can change without code changes (Chapter 1).

From a template: keep, replace, configure

Keep: the platform Compose, Makefile, Dockerfiles, CI, releasessign-in, sessions, rolesdatabase access, pools, timeouts, RLSrate limits, streaming, errors, logs, metricsthe model seam and the mockthe eval harness; redactionaudit trail, off switch, retentionproduction's refusals; the scansdeploys, backups, drills, monitoring Replace: the domain the data model and its migrationsroutes, visibility queries, schemasthe prompt and its contexteval cases, judge answers, baselinethe retrieval benchmarkthe mock's canned answerthe UIbrowser tests, load scripts Configure the model providerthe identity providerthe public URL, secretsthe registry and hosts Rewrite the personal-datainventory, for yourown tables
Figure 19.2What a new service changes when it starts from the reference template. Retrieval from documents is "keep or drop": point it at the new domain's documents, or leave the embedding model unset and the assistant uses its own context alone.
In the reposcripts/new-project.shmaking a copy your own

The template is a GitHub template repository; a copy starts with one commit and no link back. This script renames it, and its header says exactly what it touches and what it leaves alone.

  • The cookie prefix is renamed too: browsers scope cookies by host, not port, so two services from one template on localhost would sign each other out.
  • The docs are left alone on purpose: they describe the template, and are yours to rewrite.
  • It was tested on a fresh copy with the longest name it accepts; the first run failed lint, which is why the names now come from one constant.
#!/usr/bin/env bash
# Make a fresh copy of this template your own project:
#   scripts/new-project.sh <name> <github-owner>      e.g. claims-helper acme-corp
#
# Replaces the template's identifiers everywhere outside the docs:
# - "Parzon/triage-assistant" (repository links: runbook URLs in the alert
#   rules, the security-advisory link, the VM bootstrap's clone URL);
# - "@Parzon" (CODEOWNERS);
# - "triage-assistant" (compose projects, image names, the Grafana
#   folder, the page heading, the api's title);
# - the cookie names' prefix (COOKIE_PREFIX in app/sessions.py). Cookies
#   are scoped by host, not port: two projects from this template on
#   localhost would otherwise sign each other out.
#
# Left alone on purpose:
# - the docs, which describe the template and are yours to rewrite;
# - the database role names in .env.example;
# - the demo realm ("triage", client "triage-web"), which your
#   organisation's identity provider replaces;
# - app/triage.py, the AI module a new project rewrites anyway.
#
# Run it once, on a fresh copy, then `make setup && make check`.
# Portable: GNU and BSD (macOS) sed both accept `sed -i.bak`.

Replacing the domain, in order

1 Requirementswhat, for whom, success 2 Data model+ RLS and its tests 3 Routes, queriesvisibility, roles, 404s 4 Eval casesbefore the prompt 5 Prompt, contextretrieval measured first 6 The mockits canned answer 7 UI, browser testssign-in, streaming, isolation 8 Load and drillsrerun on new endpoints 9 DocsREADME and overview; a runbook section per new alert; a decision record for each choice a newcomer would question A prompt cannot fix a document section that was never retrieved: measure retrieval on your own corpus first.
Figure 19.3The order the template's guide prescribes. Eval cases come before the prompt for the same reason tests come before a refactor: they say what good looks like before anyone is tempted to adjust the definition.

What not to change without a decision record

Some rules are what stop copies of a template drifting into different platforms. In the reference template they are the access model (roles, 404 for invisible data, row-level security), the deploy path (rolling deploys, expand-and-contract migrations), the timeout chain, the split between liveness and readiness, and the eval gate for prompt and model changes. Change one only on purpose, and write down why. To take a later fix from the template, add it as a remote and cherry-pick one commit at a time; never merge it wholesale, because the histories are unrelated and it would bring the template's domain back.

Team defaults for development

DEVELOPMENT DATABASES Sharedcheap; collisions and dirtystate; temporary at best Per developer: the defaultin Compose, seeded, schemacurrent through migrations Per pull requestcreated in CI, destroyed after:integration tests Production data in development: essentially never, raw an anonymized snapshot into staging, never laptops · synthetic data for load tests · one minimal row under a documented exception Credentials: no shared logins. Each developer has their own cloud role, database user and development API keys, pulled from a secrets manager into a local file that Git ignores.
Figure 19.4Development defaults for a team. Cloud development environments earn their cost when laptops cannot run the stack, drift between machines becomes a real problem, or code must never touch a laptop.

Review standards matter more, not less, when much of the code is written with AI coding tools. No merge without a human review; CI green is mandatory; tests are the real gate, and untested generated code is as unmergeable as untested human code; pull requests stay small, because generated diffs are large and large diffs get rubber-stamped; and a reviewer must be able to explain why the code works. Watch for the typical failure patterns: invented APIs or parameters, silently swallowed exceptions, clever one-liners, and tests that assert something trivial (Handbook 1, Chapter 18).

Day one with an agent

A new service that an agent will mostly write needs its guardrails before its first feature, for the same reason the pipeline comes before the first feature: everything the agent writes afterwards is shaped by what the repository enforces on day one. The hour it takes is the cheapest hour of the project.

1 · The contract, under 40 linesthe commands; the non-obvious conventions;"never edit lint config, CI or hooks to pass";pointers to the longer documents 2 · Hooksdeny edits to config, pipeline, lockfiles;format and lint after each edit;the check command before it may stop 3 · Owners and required checksowners on every guardrail file;required status checks on main;one human approval 4 · A problem-statement templateoutcome with constraints; the acceptancecheck that proves it done; the risk tierthat sets how strict the human review is 5 · Working shapeone worktree and branch per agent session;a plan file per feature, edited by a person;plugins measured before they are adopted 6 · Then the first featurethe health endpoint, through the pipeline,as the chapter began; the agent's firsttask is also the first test of the gates Steps 1 to 3 are a template: copy them from the reference build, then change only what the new domain needs.
Figure 19.5The first hour of a service an agent will write. The contract, the hooks and the owners file are platform, like CI: kept from the template, not rewritten per service.

The contract stays short because the research on repository instruction files found that longer ones did not raise success and did raise cost; it holds what a tool cannot express. The hooks are where "the agent runs the checks before it says done" stops being a request. The owners file is what stops a pull request from quietly loosening a rule. And the problem-statement template is the engineer's main artifact from now on: the agent is only as good as the outcome it was given and the check that proves it.

Traps

SymptomCauseFix
Launch slips on cloud access and approvalsRequested when needed, not in week oneRequest every slow approval at the start.
A secret must be scrubbed from historyNo secrets strategy before the first commitDecide it first; scan every commit.
Two local services sign each other outThe same cookie names on the same hostA per-service cookie prefix.
A template fix brings back the template's domainMerging the template wholesaleCherry-pick one fix at a time.
The prompt keeps being tuned and answers do not improveThe right document section was never retrievedMeasure retrieval first, on your own corpus.
Branch protection is unavailable on a private repositoryIt needs a paid plan for private repositoriesBudget for it, or use the organization's plan.
A team of one cannot merge under required reviewsNobody else can approveAn admin merge once every check is green; turn bypassing off when a second person joins.
The agent's first feature ignored half the conventionsThe contract was written after the feature, or runs to pagesUnder forty lines on day one: commands, the non-obvious, the prohibitions; the rest in hooks.
An unattended agent run edited the pipelineNo protected paths, no turn limit, broad credentialsA hook denying edits to configuration and pipelines; a turn budget; a sandbox with an allowlist.

Questions

  1. What should a new service's first deliverable be, and why?

    Answer

    A health endpoint built by CI and deployed by CD to the real environment. It proves the whole path (access, networking, TLS, DNS, the pipeline) before any feature depends on it.

  2. Starting from a template, what is kept, what is replaced, and what is configured?

    Answer

    Kept: the platform (build, deploy, sign-in, data access, streaming, observability, evals harness, safety controls). Replaced: the domain (data model, routes, prompt, eval cases, UI, tests). Configured: the providers, URLs, secrets, registry and hosts.

  3. Why do eval cases come before the prompt?

    Answer

    They define what a good answer looks like before anyone is tempted to adjust the definition to fit the output, the same reason tests come before a refactor.

  4. What is the default development database for a team, and what never goes into it?

    Answer

    A database per developer in containers, seeded and migrated. Raw production data never does; use anonymized snapshots in staging, synthetic data, or a minimal row under a documented exception.

A

Appendix

Glossary

Terms used in this handbook, each with the chapter that explains it. AI terms are in Handbook 1's glossary; delivery and cloud terms in Handbook 3's.

ADR
An architecture decision record: one decision, its context, the alternatives and why, kept forever and superseded rather than edited. Chapter 1
Bind mount
A host directory mapped into a container, live both ways; the development pattern for hot reload. Chapter 12
Burn rate
How fast an error budget is being spent relative to plan; alerts page on fast burns over short windows. Chapter 17
Cardinality
The number of distinct label combinations of a metric; each is a separate time series held in memory. Chapter 14
cgroup
A Linux control group: the kernel feature that limits and accounts a process group's memory, CPU and other resources. Chapter 3
Compose overlay
A Compose file merged over a base file for one environment: development by default, production by name. Chapter 12
Connection pool
A set of open database connections reused across requests, sized against the database's limit. Chapter 7
Content Security Policy (CSP)
A response header telling the browser which sources of scripts, styles, images and connections a page may use. Chapter 9
Coordinated omission
In a closed load model, requests delayed by a stall delay the requests behind them, whose latency is then never recorded. Chapter 16
CSRF
Cross-site request forgery: another site making a signed-in browser send a request; blocked by checking the request's origin. Chapter 8
Error budget
The failure an SLO allows over its window; while it lasts, ship; when it is spent, reliability work comes first. Chapter 17
Event loop
The scheduler of an async runtime: one thread running many tasks, switching only where they wait. Chapter 5
Event-loop lag
How late the event loop runs a timer: a direct measure of saturation in async services. Chapter 5
Expand and contract
Changing a schema in steps (add, migrate, switch, remove) so old and new code both work throughout a deploy. Chapter 6
EXPLAIN ANALYZE
Postgres's report of how it actually ran a query; meaningful only on realistic data and load. Chapter 6
Fail open, fail closed
What a component does when a dependency is down: allow the request (open) or refuse it (closed). Chapter 15
Golden signals
Latency, traffic, errors and saturation: the four signals that describe a service's health. Chapter 14
Graceful shutdown
On SIGTERM, stop accepting new work and finish what is in flight within a grace period. Chapter 3
Health check
An endpoint or command a platform uses to decide whether a process is alive (liveness) or should get traffic (readiness). Chapter 12
Image digest
The content hash that identifies a container image exactly; a tag is a movable name. Chapter 12
Index (database)
A structure that lets the database find rows without scanning the table; chosen from the queries, at a cost on writes. Chapter 6
Keep-alive
Reusing one TCP connection for many requests; the side that closes idle connections first must be the proxy. Chapter 11
Least privilege
Every component gets only the rights its job needs: database roles, container capabilities, network exposure. Chapter 9
Liveness and readiness
Liveness: is the process alive (checks nothing external). Readiness: should traffic come here (checks hard dependencies). Chapter 15
Metastable failure
A slow state that sustains itself after its trigger is gone, such as a pool stuck in overflow churn. Chapter 7
Migration
A versioned, reviewed change to the database schema, replayed in order on every database. Chapter 6
Modular monolith
One deployable with visible internal boundaries; split only for a different scaling profile, cadence or failure domain. Chapter 2
Multi-stage build
A Dockerfile with several stages, so build tools stay out of the image that ships. Chapter 12
Named volume
Docker-managed storage that survives a container being recreated; removed only on request. Chapter 12
Namespace (Linux)
The kernel feature that gives a process a private view: its own process ids, network stack and filesystem root. Chapter 3
OIDC
OpenID Connect: sign-in delegated to an identity provider, which returns signed tokens about the user. Chapter 8
OOM kill
The kernel killing a process that exceeded its memory limit (exit code 137). Chapter 3
Open load model
A load test that starts requests at a fixed rate regardless of response times, as independent users do. Chapter 16
p95
The 95th-percentile latency: 95% of requests were faster. Averages hide the tail users feel. Chapter 16
PgBouncer
A connection pooler in front of Postgres; in transaction mode, a server connection is lent for one transaction at a time. Chapter 7
PKCE
Proof Key for Code Exchange: a secret per sign-in that binds the authorization code to the client that asked for it. Chapter 8
Rate limit
A cap on requests per client per time window, kept in a store shared by every worker. Chapter 11
Request id
An id stamped on a request by the proxy in front of the application, or by the application itself, carried through every log line, response header and error body. Chapter 14
Reverse proxy
A server in front of the application that terminates TLS, routes requests and serves files. Chapter 11
Row-level security
Postgres policies that filter rows per transaction, so a forgotten WHERE clause returns nothing instead of leaking. Chapter 7
Runbook
Written steps for an alert or a situation: what users see, what to check first, what caused it before. Chapter 17
Server-Sent Events (SSE)
A one-way stream of text events from server to browser over an ordinary HTTP response. Chapter 10
Session cookie
A cookie holding an opaque token that the server looks up to identify a signed-in user. Chapter 8
SLO
A service level objective: a target on a measured signal over a window, such as 99.5% of requests without error over 30 days. Chapter 17
Span
One timed step in a trace, with attributes; a trace is a tree of spans for one request. Chapter 14
Statement timeout
A database-side limit on how long a query may run; the server stops the work itself. Chapter 15
Walking skeleton
The thinnest end-to-end version of a system, such as a health endpoint deployed by the real pipeline, built before any feature. Chapter 19
X-Forwarded-For
A header carrying the client's address through proxies; trustworthy only as far as proxies you control: the outermost one overwrites it, or you trust only the known proxies' addresses and take the right-most entry they added. Chapter 11
B

Appendix

Decision index

RecordDecisionDiscussed in
ADR-000119
ADR-0002Valkey instead of Redis for the rate-limit store18
ADR-000312
ADR-0004The rate limiter fails open15
ADR-00057
ADR-0007Streaming answers: JSON SSE events, heartbeats, and cancellation to the provider10
ADR-0008Metrics with Prometheus, dashboards and alerts as code, alerts delivered to the app14
ADR-0009Performance defaults chosen from load tests16
ADR-0010Database timeouts live on the server side; failures answer in seconds15
ADR-0012HTTPS terminates at a Caddy edge in front of nginx11
ADR-0013Sign-in with the organization's identity provider; teams own alerts8
ADR-0014Postgres enforces team isolation (row-level security)7
ADR-0015Rollbacks roll back the code, never the schema1
ADR-001814
ADR-0021Trim the template to what a project uses19
ADR-0023Production is safe by default, and refuses unsafe settings9
ADR-0024An off switch for the assistant17
ADR-00259
C

Appendix

Repository index

D

Appendix

Trap index

SymptomFixChapter
A setting changed in .env has no effectList every setting with no value under environment:; check it in lint.1 · The anatomy of a modern service repository
Two stacks in one directory interfere: the dev api reports a release's versionGive the second stack its own ports and project name on the command line.1 · The anatomy of a modern service repository
The development overrides silently stop applyingList it explicitly together with the other overlay.1 · The anatomy of a modern service repository
A service enabled by a profile disappearsSet the full list instead.1 · The anatomy of a modern service repository
An empty setting crashes a server at importTreat empty as unset in configuration code, and test it.1 · The anatomy of a modern service repository
A dashboard change made in the tool is goneChange the generator, then regenerate.1 · The anatomy of a modern service repository
A merged decision record was quietly rewrittenWrite a new record that supersedes the old; protect the folder with code owners.1 · The anatomy of a modern service repository
Streaming worked in development and arrived all at once in productionProve changes in the production shape before calling them done.2 · The system end to end
The production image crashed on an import that worked in developmentBuild and run the production image in CI; check every module imports.2 · The system end to end
A per-user limit allows N times the configured rateShared state in a shared store; the api stays stateless.2 · The system end to end
The VM or the front proxy is down and no alert firesAn outside-in uptime check, and a dead man's switch for monitoring itself.2 · The system end to end
A container dies with exit 137 and no error in its logRead the kernel log and the cgroup's OOM counter; raise the limit or fix the leak.3 · Linux and processes
Memory grows past the limit without any killSet the swap limit equal to the memory limit.3 · Linux and processes
A stopped-on-purpose container stays down after a "crash test"Simulate crashes with SIGKILL from the host's process namespace.3 · Linux and processes
An unhealthy container is never restartedDefine the check where the orchestrator reads it (Kubernetes probes, an ECS task-definition check), or make the process exit.3 · Linux and processes
A worker pool is far too large inside a containerRead the cgroup's CPU quota.3 · Linux and processes
A wait loop never ends, or a kill loop kills itselfTrack and kill by process ID.3 · Linux and processes
A script carries on after a failed commandTest the condition explicitly with if.3 · Linux and processes
A rename put a directory inside anotherCheck the target does not exist, or use mv -T.3 · Linux and processes
A password was pasted into a chat to get past sudoThat password is burned: rotate it.3 · Linux and processes
The service works on the box but nobody else can reach itBind to 0.0.0.0 inside the container, and publish deliberately.4 · Networks: DNS, TCP, TLS and HTTP
A port the firewall blocks is reachable from the networkPublish on loopback, or filter in Docker's own chain.4 · Networks: DNS, TCP, TLS and HTTP
A client reports a name-resolution error, not "refused", when a service is downTreat resolution errors as "unavailable" and answer 503.4 · Networks: DNS, TCP, TLS and HTTP
A database came back but clients failed for another 15 secondsLower the negative DNS cache time.4 · Networks: DNS, TCP, TLS and HTTP
Sequential test clients look like one clientRun clients concurrently, or identify them some other way.4 · Networks: DNS, TCP, TLS and HTTP
Every local port suddenly insists on HTTPSDisable HSTS locally; set a long max-age only for a real domain.4 · Networks: DNS, TCP, TLS and HTTP
Every request on a worker slows down at once, health checks includedUse async clients, or move blocking work to a thread; watch event-loop lag.5 · Concurrency and the Python backend
Timeouts and fail-opens fire at moderate CPUTreat loop lag as the saturation signal; scale before it rises.5 · Concurrency and the Python backend
A probe with a 2-second timeout took over 10 secondsRun the check as a task, wait with a deadline, and abandon it rather than cancel.5 · Concurrency and the Python backend
A canceled stream kept the model call running and billingClose generators explicitly (contextlib.aclosing) and stop the upstream call in finally.5 · Concurrency and the Python backend
A request-scoped database session held its connection for a whole streamed answerScope the session to the function, and load data before streaming.5 · Concurrency and the Python backend
The request id is missing from error responsesHandle errors inside the middleware that sets the id.5 · Concurrency and the Python backend
A runtime check vanished in productionUse an explicit check and raise.5 · Concurrency and the Python backend
A worker hangs until it is killedSet PYTHONBREAKPOINT=0 in production images.5 · Concurrency and the Python backend
Bursts of 502 under loadLeave recycling off unless memory growth is measured.5 · Concurrency and the Python backend
The production image crashed on import httpxRun the production image in CI and import every module.5 · Concurrency and the Python backend
A hung dependency stalls every synchronous endpointBound every wait; prefer async endpoints.5 · Concurrency and the Python backend
A renamed column lost its dataRead every generated migration; write renames explicitly, or as expand and contract.6 · Postgres: schema changes and indexes
A migration logged success but changed nothingRun the SET inside the migration's transaction. Make it a session SET, not SET LOCAL, if it must outlast an autocommit block.6 · Postgres: schema changes and indexes
Writes stall during a deployBuild indexes concurrently; add foreign keys NOT VALID, then validate.6 · Postgres: schema changes and indexes
Every query on a table waits during a migrationA lock timeout on every migration; retry off-peak.6 · Postgres: schema changes and indexes
A query that was fast in testing is slow in productionMeasure under realistic load with production-sized data.6 · Postgres: schema changes and indexes
A copied table lost its auto-incrementAdd INCLUDING IDENTITY.6 · Postgres: schema changes and indexes
A command defaults to a database named after the userAlways pass the database name explicitly.6 · Postgres: schema changes and indexes
An upgraded Postgres container refuses its data directoryDump and restore, or pg_upgrade; never just change the image tag.6 · Postgres: schema changes and indexes
Monitoring itself used a quarter of the database's timeMeasure what monitoring costs, and tune its queries and interval.6 · Postgres: schema changes and indexes
Parallel queries fail in a containerRaise shm_size.6 · Postgres: schema changes and indexes
The pooler looks healthy but every query failsConfigure SCRAM; make the readiness check run a real query.7 · Connections, pooling and row-level security
One user's tenant setting appears in another user's requestTransaction-local set_config(..., true), with a test.7 · Connections, pooling and row-level security
After a network fault, every request waits for a pooled connectionA TCP user timeout on database connections, so dead ones fail and are replaced (Chapter 15).7 · Connections, pooling and row-level security
Per-session timeouts silently do nothingSet timeouts on the database role.7 · Connections, pooling and row-level security
Latency stays high after a burst is overA fixed-size pool with no overflow.7 · Connections, pooling and row-level security
A pool-usage gauge said 2 while 10 requests were stuckSample the pool's own accounting.7 · Connections, pooling and row-level security
A test's cleanup deleted nothingDeclare the context in fixtures; test as the app role, because the owner bypasses policies.7 · Connections, pooling and row-level security
A policy is slow on large tablesWrap it in a scalar subquery so it runs once per query.7 · Connections, pooling and row-level security
An insert is refused although the write policy allows itMake sure the writer may read its own row, or insert without returning.7 · Connections, pooling and row-level security
After an upgrade, a running older release sees no rowsExpand first (send the context), contract in the next release; never skip it.7 · Connections, pooling and row-level security
Sign-in works with the demo provider and fails with the real oneUse Lax, and pin it with a test; check Origin for CSRF.8 · Sign-in, sessions and roles
Tokens are rejected for naming the wrong issuerConfigure the provider with one public issuer, and point the api's discovery at the internal address.8 · Sign-in, sessions and roles
The first sign-in on a new container fails with "unknown key"Mark "never" as minus infinity, not 0.8 · Sign-in, sessions and roles
The token library cannot load the provider's keysKeep only signing keys (use: sig).8 · Sign-in, sessions and roles
The provider was down and nothing failed or alertedA periodic reachability check, reported in readiness, with an alert.8 · Sign-in, sessions and roles
Sign-out leaves the cookie in placeSend the same attributes when clearing it.8 · Sign-in, sessions and roles
A client secret with + or % fails against strict providersEncode them as the standard requires.8 · Sign-in, sessions and roles
Entra ID groups arrive as unreadable idsUse app roles for the claim; Google has no groups claim at all.8 · Sign-in, sessions and roles
A user's identity changed when their email changedKey users by issuer and subject.8 · Sign-in, sessions and roles
With sign-in, the cheapest request collapsed at 1,000 requests a secondOne query in the request's transaction: p95 from 1.07 s to 19.7 ms at 1,000 requests a second. The CPU still doubles; that is the accepted cost of signing in.8 · Sign-in, sessions and roles
A leaked secret keeps working after it was removed from the repositoryRotate first, then remove.9 · Secrets, personal data and least privilege
Secrets appear in a build logPass secrets to containers by name (-e NAME), taking the value from the environment.9 · Secrets, personal data and least privilege
An alert-routing tool cannot read a secret from the environmentMount the secret as a file.9 · Secrets, personal data and least privilege
Erased users reappearRemove them there as part of the erasure procedure.9 · Secrets, personal data and least privilege
Personal data survives in places nobody listedA new table, log field or span attribute holding personal data gets an inventory row and a retention in the same change.9 · Secrets, personal data and least privilege
Sensitive facts survived redactionKeep content out of telemetry by default; treat model inputs as leaving the service.9 · Secrets, personal data and least privilege
The browser's EventSource cannot send the questionUse fetch and parse the stream.10 · Frontend decisions and streaming
A streamed answer arrives all at once in productionDisable buffering for the stream's location and send X-Accel-Buffering: no.10 · Frontend decisions and streaming
Markdown answers are cut off mid-streamJSON-encode every event.10 · Frontend decisions and streaming
Long thinks end in a dropped connectionHeartbeat comments more often than the smallest idle timeout in the path.10 · Frontend decisions and streaming
The spinner never stopsTreat an incomplete stream as an error of its own.10 · Frontend decisions and streaming
Stop resubmitted the questionDistinct keys; and a real-browser test, which caught it.10 · Frontend decisions and streaming
Model output could run as HTMLRender model output as text; a browser test fails if it is ever rendered as HTML.10 · Frontend decisions and streaming
Browser tests fail in CI but not locallyPin both to exactly the same version.10 · Frontend decisions and streaming
nginx keeps connecting to a dead address after the api is recreatedA runtime resolver and resolve on the upstream server.11 · The edge: TLS, nginx and proxies
Bursts of 502 on POST requestsThe proxy's idle keep-alive shorter than the backend's.11 · The edge: TLS, nginx and proxies
A request waits 30 seconds when the backend has vanishedKnow which timeout applies; keep read timeouts tight on ordinary routes.11 · The edge: TLS, nginx and proxies
Every user shares one rate limitOverwrite the forwarded header at the edge; trust it inside only from known proxies.11 · The edge: TLS, nginx and proxies
Clients can choose their own rate-limit bucketOverwrite at the outermost proxy you control.11 · The edge: TLS, nginx and proxies
Errors from the proxy are HTML pages that clients cannot parseAn error page that answers in the api's JSON shape, keeping the status and request id.11 · The edge: TLS, nginx and proxies
Security headers disappear on one routeInclude the shared security headers in every location.11 · The edge: TLS, nginx and proxies
Absolute URLs lose their portUse $http_host where the port matters.11 · The edge: TLS, nginx and proxies
Trailing-slash redirects point at the wrong host and port behind a prefix-stripping proxyTurn off automatic slash redirects.11 · The edge: TLS, nginx and proxies
The edge container refuses to start with no-new-privilegesRemove the capability; let it bind low ports through the unprivileged-port sysctl.11 · The edge: TLS, nginx and proxies
nginx advertises its versionTurn it off.11 · The edge: TLS, nginx and proxies
A port is reachable from the internet despite the host firewallBind to 127.0.0.1, or filter in the DOCKER-USER chain.12 · Containers and Compose
A rebuild after a dependency change still runs the old dependenciesRecreate anonymous volumes (--renew-anon-volumes).12 · Containers and Compose
Tests run on stale dependenciesrun --build.12 · Containers and Compose
A memory limit is exceeded with no out-of-memory killSet the swap limit equal to the memory limit.12 · Containers and Compose
A crashed container stayed downunless-stopped; simulate crashes by killing the process from the host.12 · Containers and Compose
An overlay's port did not replace the base'sKeep ports out of the base, or use !reset.12 · Containers and Compose
The dev override stopped applyingList the override explicitly alongside other files.12 · Containers and Compose
A service crashed parsing a settingList the key with no value instead.12 · Containers and Compose
docker compose up rolled production backRecord the deployed tag where Compose reads it, as the deploy script does.12 · Containers and Compose
A script cannot find a value that Compose seesAsk Compose (config), and fail when the answer is empty.12 · Containers and Compose
Logs are goneShip logs off the host; do not rely on the container's file.12 · Containers and Compose
Files written in a bind mount are owned by rootRun development containers as your own UID.12 · Containers and Compose
Image pulls fail across an officeLog in, or use a registry mirror.12 · Containers and Compose
The database refuses its data directory after an upgradeDump and restore, or pg_upgrade.12 · Containers and Compose
All tests pass; production crash-loopsTest the production image itself in CI.13 · Testing, code style and review
A component test passes before and after a real fixA browser test for anything involving focus, forms or timing.13 · Testing, code style and review
Coverage drops after moving to an async ORMConfigure coverage for the concurrency library used.13 · Testing, code style and review
A green eval report while the judge failed every callA missing verdict fails the run.13 · Testing, code style and review
A green report for a wrong answerRead the answers, not just the scores; strengthen the case.13 · Testing, code style and review
A redaction function passed every unit test and missed 37 of 54 real secretsScore it against a corpus of real shapes, held out from tuning.13 · Testing, code style and review
An insert fails with a policy error after row-level security is addedAn integration test for every write path.13 · Testing, code style and review
A test fails one run in twentyFind it; never add retries.13 · Testing, code style and review
Every check is green, complexity and duplication keep climbingComplexity, duplication and dead-code checks against a baseline, in CI; mutation on the diff.13 · Testing, code style and review
100% coverage, and a planted bug survivesPatch coverage beside mutation testing; read the assertions in review.13 · Testing, code style and review
A pull request loosened the lint rules to passOwners on the guardrail files; a hook that denies edits to them in the agent's loop.13 · Testing, code style and review
Each scrape shows a different, smaller countThe client's multiprocess mode.14 · Observability and debugging
Prometheus memory grows without boundTemplates and fixed sets only, with a test.14 · Observability and debugging
A panel says "No data" on a healthy system(… or vector(0)).14 · Observability and debugging
An alert on "service down" never fires when the service is removedAn absent() rule.14 · Observability and debugging
Timeouts while CPU looks half idleWatch event-loop lag as saturation.14 · Observability and debugging
"Trace not found" for most request idsOnly return a trace id for a kept trace.14 · Observability and debugging
Restarts are slow when the trace backend is downA short export timeout.14 · Observability and debugging
A log query at a given time finds nothingRelative times, or an explicit zone.14 · Observability and debugging
An out-of-memory kill is not in Docker's stateThe kernel log and the container-level counter.14 · Observability and debugging
A breakpoint left in code freezes a worker in productionDisable breakpoints in the production image.14 · Observability and debugging
Pool connections stay in use after the database recoversCap time on the server; drill a freeze under load and check the pools.15 · Reliability: timeouts, failures and drills
Requests hang for two minutes when the database is downSeconds, with a clear 503.15 · Reliability: timeouts, failures and drills
The pool stays full after a network fault, with the database healthyDrop unacknowledged connections (TCP_USER_TIMEOUT); drill a fault that changes the address.15 · Reliability: timeouts, failures and drills
A database blip restarts every replicaLiveness checks nothing external.15 · Reliability: timeouts, failures and drills
A cache outage takes the whole site downReport it as degraded; keep answering 200.15 · Reliability: timeouts, failures and drills
The drill says a dependency failure was harmlessVerify each injection; report a failed one as such.15 · Reliability: timeouts, failures and drills
A crashed container never restarts in a drillKill the process from the host, as the kernel would.15 · Reliability: timeouts, failures and drills
A short outage leaves no trace in metricsLook in logs and request errors.15 · Reliability: timeouts, failures and drills
A retried write is applied twiceDeadline propagation and idempotency keys.15 · Reliability: timeouts, failures and drills
The load test looks fine; production is slowA fixed arrival rate.16 · Performance and load testing
A fast query in EXPLAIN is slow under loadProfile under realistic load and data volume.16 · Performance and load testing
Latency climbs with the generator's CPUWatch its CPU; use an efficient tool or more machines.16 · Performance and load testing
The test measures 429sRaise them for the run.16 · Performance and load testing
Errors appear at half CPUWatch loop lag; size budgets for a busy loop.16 · Performance and load testing
Periodic 502 bursts under loadRecycle only to contain a measured leak.16 · Performance and load testing
A memory-leak hunt reports a leak that is not thereCollect before each snapshot; compare two run lengths, since a leak grows with N and a cache does not.16 · Performance and load testing
A latency gate in CI is flakyGate performance on a dedicated, quiet environment, if at all.16 · Performance and load testing
People are paged for blipsBurn-rate alerts on the SLO, with a short and a long window.17 · Incidents, runbooks and service levels
Switching a feature off pages the on-call engineerCount them separately and leave them out of the error alert and the SLO.17 · Incidents, runbooks and service levels
An error budget is spent and nothing changesAgree it before the first incident.17 · Incidents, runbooks and service levels
The runbook is wrong during the incidentGame days, and fixing the runbook the same day.17 · Incidents, runbooks and service levels
The off switch cannot be used during the outageA second path from the host, documented in the runbook.17 · Incidents, runbooks and service levels
A restore takes far longer than expectedTime a full-size restore.17 · Incidents, runbooks and service levels
The review produced a document and no changeIssues with owners: a drill, an alert or a runbook line.17 · Incidents, runbooks and service levels
Each user gets several times their rate limitA shared store once there is more than one process.18 · The stack catalog
A license change forces an urgent migrationCheck before adopting; prefer open-license equivalents.18 · The stack catalog
The team cannot explain how a component failsRemove it, or decide and drill its failure mode.18 · The stack catalog
Builds fail on ARM laptops or cloud instancesCheck for both architectures before adopting.18 · The stack catalog
The document says one version, production runs anotherLockfiles and image lines are the truth.18 · The stack catalog
Launch slips on cloud access and approvalsRequest every slow approval at the start.19 · Starting a new service
A secret must be scrubbed from historyDecide it first; scan every commit.19 · Starting a new service
Two local services sign each other outA per-service cookie prefix.19 · Starting a new service
A template fix brings back the template's domainCherry-pick one fix at a time.19 · Starting a new service
The prompt keeps being tuned and answers do not improveMeasure retrieval first, on your own corpus.19 · Starting a new service
Branch protection is unavailable on a private repositoryBudget for it, or use the organization's plan.19 · Starting a new service
A team of one cannot merge under required reviewsAn admin merge once every check is green; turn bypassing off when a second person joins.19 · Starting a new service
The agent's first feature ignored half the conventionsUnder forty lines on day one: commands, the non-obvious, the prohibitions; the rest in hooks.19 · Starting a new service
An unattended agent run edited the pipelineA hook denying edits to configuration and pipelines; a turn budget; a sandbox with an allowlist.19 · Starting a new service