RUL-019 — Config modules: Pydantic BaseSettings + auto-loaded .env
Ruling
Central config modules (core/infra_config.py) use Pydantic BaseSettings, and env files must be auto-loaded (_load_dotenv()) by every entrypoint — never left to direnv or manual sourcing. _load_dotenv() loads the secrets-only .env first, then overlays the active ORCHESTRATA_ENV_PROFILE (env/{dev,test,prod}.env, endpoint-only) with override=True. This is distinct from RUL-003: RUL-003 governs call-time reads for override-sensitive code paths; this ruling governs the shape of the config module itself.
Evidence
core/infra_config.pydocstring: "Infrastructure configuration using Pydantic Settings ... environment variable fallbacks, validation, and type safety via Pydantic BaseSettings."- AGENTS.md line 39 (Hard Rule 5) and line 624: both
start_test_services.pyand the gateway CLI call_load_dotenv()at startup.
Enforcement
- gate scripts/check_env_contract.py RUL-019
- none (judgment)
Verification
The loading clause is mechanized; the BaseSettings clause stays judgment. Two bullets, deliberately — they cover different halves of this ruling.
scripts/check_env_contract.py (invoked from scripts/health_check.py as "Env contract (RUL-019)") is a static AST gate with two rules:
- Rule A — exactly one canonical loader. A module in which one scope both names a
.envfile and writesos.environmust implement the full two-phase contract (secrets-only.envfirst, then theORCHESTRATA_ENV_PROFILEoverlayenv/<profile>.envwithoverride=True). A module that parses.envinto a returned dict without touchingos.environis correctly ignored. - Rule B — every entrypoint reaches it. An entrypoint (
__main__guard,[project.scripts], or a systemdExecStarttarget — backslash continuations resolved) that resolves a required-key infra seam, or reads a required key throughos.environ, must have that loader in its import closure, or it depends on systemdEnvironmentFile=injection and cannot start from a bare shell.
The Rule-B trigger search is bounded (--max-depth 1 default) after measuring that an unbounded closure produced 16 findings, 14 of them traceable to one lazily-executed call site (core/graph/adapters/bolt_async.py:88) — day-one false positives are how a gate gets switched off. The bound errs toward false negatives and is therefore never invisible: every run prints the deep_scope_only entrypoints that would be in scope at unlimited depth, and --max-depth -1 audits that region.
The gate landed RED against the live repo — 4 genuine violations, which were then fixed rather than suppressed:
- Rule B
src/orchestrata_ultimate/agents/runtime_main.py— the kernel entrypoint (bothdeploy/systemd/orchestrata-kernel.serviceanddeploy/orchestrata-test/orchestrata-kernel.serviceExecStart, plus a__main__guard) calledcxp.endpoint_config.cxp_grpc_portat line 35 and reached no loader: the beating-heart kernel could not start from a bare shell, only from systemd withEnvironmentFile=injection. - Rule B
src/orchestrata_ultimate/knowledge_graph/server.py—__main__guard, calledcxp.endpoint_config.require_portat line 29, reached no loader. - Rule A
src/orchestrata_ultimate/gateway/cli.pyandsrc/orchestrata_ultimate/kernel/cli_client.py— two additional hand-rolled mutating.envloaders, neither implementing the two-phase contract (each missingORCHESTRATA_ENV_PROFILE, theenv/<profile>.envoverlay, and theoverridewrite).
Resolution (2026-08-05): src/orchestrata_ultimate/core/env_bootstrap.py::load_env_contract now holds the single canonical two-phase implementation (lifted from scripts/start_test_services.py::_load_dotenv, the only correct one that existed); agents/runtime_main.py and knowledge_graph/server.py call it, and gateway/cli.py / kernel/cli_client.py are folded onto it. No baseline, no allowlist, no ratchet — the four violations were fixed in the code, not suppressed in the gate. Re-measured: [env-contract] OK — RUL-019 satisfied, 900 modules, 29 entrypoints, 5 in scope at depth 1, 4 ExecStart targets resolved.
False-positive hardening, same day — two classes found by adversarial review, both fixed, both recall-neutral (the red-first regression of the kernel fix still fails, and a second hand-rolled mutating loader is still caught):
- Rule A correlated nothing. "Names a
.envfile" and "writesos.environ" were independent module-wide facts with no dataflow link, so any pair of unrelated occurrences accused the module. Adding the project-mandated free-threading guardos.environ.setdefault("OPENBLAS_NUM_THREADS", "1")tocore/infra_config.py— whose only.envmention is pydantic'senv_file=".env"in a class body — reported it as a rogue loader and told the author to "fold this onto" the canonical loader. Eight live modules name a.envpath; each was one unrelatedos.environwrite away from the same accusation. Fixed: the two facts must co-occur inside one scope (a function body, or module top level excluding function and class bodies). - Rule B recorded a key from any call. A key string in the first positional argument of any call marked the module as reading infra, so a one-line
_key_help("REDIS_HOST")returning an f-string pulledpostman.cliinto scope and failed it for "depends on systemdEnvironmentFile=injection". Fixed: a key counts only when something resolves it — anos.environlookup, or a call intoRESOLVER_SEAMS. Both genuine findings are seam calls and are unaffected; as a side effect thedeep_scope_onlyover-report shrank from 12 entrypoints to 11.
The remaining bullet is honest: no lint can enforce "new config modules must use Pydantic BaseSettings" — infra_config.py is a reference implementation to imitate, not a pattern a gate can check for absence elsewhere. That clause stays judgment.