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
Literalfield 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'slog_format-based guard. - Alternative considered: infer the environment from a hostname / a
DEBUGflag / 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:
app/pii/encryption.py'slog_formatguard is deleted, not re-pointed. The fail-closed startup guard (ADR-0103) already refuses to boot production withpii_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.pii_encryption_enableddefaults toTrue. 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.- 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. app_envdefaults todevelopment, notproduction. 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_envdefaults todevelopment, 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 toproductionwould make everypytestrun and every localuvicornfail until the developer discovered the field, which reliably teaches people to disable guards. The mitigation is to makeAPP_ENV=productiona 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_enabledon 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_formatreturns to meaning only what it says: the log format.- The
testandstagingvalues 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_URLpoints 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.