Skip to main content

ADR-0104: app_env is the single signal for "am I in production"

Date: 2026-07-11 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 4.8

Decision context:

  • Latency: none. One enum read at boot.
  • Dependency surface: zero new packages. A Literal field on the existing pydantic-settings model.
  • Debuggability: improved. "Why did this behave as production?" currently has two different answers depending on which subsystem is asked; after this it has one.
  • Reversibility: single field; ~hours to revert. The removal of the weak credential defaults is the only irreversible-in-practice part (a deployment relying on them would already be broken).
  • Blast radius: additive (a new field, a new guard). The one substitutive change is deleting pii/encryption.py's log_format-based guard.
  • Alternative considered: infer the environment from a hostname / a DEBUG flag / the presence of a secret. Rejected — every one of those is another proxy.

Context

backend/app/config.py had no environment concept at all — no app_env, no is_production, no environment-conditional behaviour. Two consequences followed.

1. Subsystems that needed to know "am I in production" invented proxies. app/pii/encryption.py:121-126 inferred production from a logging setting:

# production. log_format='json' is the production signal (console = dev,
if not enabled and getattr(settings, "log_format", "console") == "json":
raise RuntimeError("pii_encryption_enabled=False in production ...")

log_format defaults to console. So a production deploy that left the default logging format stored all PII in plaintext, silently — the guard never fired. The proxy was a reasonable improvisation in the absence of a real signal, and it failed exactly the way proxies fail.

2. Configuration failed open. The shipped defaults are minio_access_key="minioadmin", minio_secret_key="minioadmin", keycloak_admin_password="admin", neo4j_password="trustrelaydev", minio_use_ssl=False, and a localhost JWKS URL. A production boot with one missing environment variable does not crash — it runs with default credentials and no TLS to object storage. For a system whose entire design ethos is fail-closed, the configuration layer was the one place that failed open.

This is the same structural mistake as ADR-0103's: one signal standing in for another. There, one flag meant two capabilities. Here, a logging setting meant an environment.

Decision

Add a single, explicit environment field:

app_env: Literal["development", "test", "staging", "production"] = "development"

It is the only signal for "am I in production." Every subsystem that needs to know reads it. No subsystem may infer the environment from anything else.

The concrete consequences:

  1. app/pii/encryption.py's log_format guard is deleted, not re-pointed. The fail-closed startup guard (ADR-0103) already refuses to boot production with pii_encryption_enabled=False, and it does so at boot rather than at first encryption call. A second guard on a second signal is the problem, not a defence.
  2. pii_encryption_enabled defaults to True. A default that stores PII in plaintext is not a safe default, and the only thing that made it tolerable was the (broken) proxy guard.
  3. The weak credential defaults are refused in production by assert_production_safe (ADR-0103), which reports every violation in one message rather than failing on the first.
  4. app_env defaults to development, not production. See Consequences.

Consequences

Positive

  • Exactly one answer to "am I in production," readable by any subsystem.
  • PII is encrypted by default; the only way to store it plaintext is to say so explicitly, and production refuses to start if you do.
  • A missing environment variable in production is a crash with a named cause, not a silent fallback to minioadmin.
  • M1 and M2 (TLS enforcement, secrets provider, environment separation, deployment promotion) all have a signal to hang off. This ADR is a prerequisite for both.

Negative

  • app_env defaults to development, so a production deployment that forgets to set it gets no guard at all. This is a real hole and it is a deliberate trade: defaulting to production would make every pytest run and every local uvicorn fail until the developer discovered the field, which reliably teaches people to disable guards. The mitigation is to make APP_ENV=production a required, asserted key in the deployment IaC (M2) — i.e. move the enforcement to the layer that actually knows it is deploying. Until M2 exists, this remains an accepted, recorded risk.
  • Every deployed environment now needs one more explicit variable.
  • Turning pii_encryption_enabled on by default means any environment with existing plaintext rows needs the re-encryption migration to run before reads succeed. Handled by the M0-W4 migration; a developer who pulls this change and skips migrations will see decryption errors, which is the correct loud failure.

Neutral

  • log_format returns to meaning only what it says: the log format.
  • The test and staging values are defined but currently gate nothing. They exist so that M1/M2 have somewhere to hang staging-specific behaviour without re-litigating the enum.

Alternatives Considered

Alternative 1: is_production: bool

  • A single boolean instead of a four-value enum.
  • Why rejected: staging needs to be not development (real credentials, real TLS, real screening) while remaining not production (a permissive alert policy, a non-customer dataset). A boolean forces staging to lie about itself, and a system that makes an environment lie is how proxies get invented in the first place.

Alternative 2: Infer the environment (hostname, DEBUG, presence of a secret)

  • e.g. "if DATABASE_URL points at localhost, we are in development."
  • Why rejected: this is precisely the defect being fixed. Every inference is a proxy, and every proxy is a guard that fires under the wrong conditions. log_format == "json" was such an inference, and it silently stored PII in plaintext for the entire life of the codebase to date.

Alternative 3: Default app_env to production (fail-safe default)

  • Safest for deployment; every local process must opt out.
  • Why rejected: it makes the default developer experience a wall of guard failures, and the reliable human response to a guard that fires constantly during normal work is to disable the guard. A safety mechanism that trains people to bypass it is worse than one with a documented hole. The hole is instead closed at the deployment layer (M2), where the intent to deploy is unambiguous.