Skip to main content

autumn_web/
config.rs

1//! Framework configuration with sensible defaults and profile-based layering.
2//!
3//! Autumn uses a five-layer configuration system where each layer
4//! overrides the previous one:
5//!
6//! 1. **Framework defaults** (this module) -- compiled into the binary.
7//! 2. **Profile smart defaults** -- per-profile values for `dev`/`prod`.
8//! 3. **`autumn.toml`** -- project-level overrides checked into source control.
9//! 4. **`[profile.{name}]` in `autumn.toml`** -- profile-specific overrides.
10//! 5. **`autumn-{profile}.toml`** -- legacy profile-specific overrides.
11//! 6. **`AUTUMN_*` environment variables** -- deployment/CI overrides.
12//!
13//! An Autumn application runs with zero configuration -- every field
14//! has a sensible default value. Override only what you need.
15//!
16//! # Local-dev `.env` files
17//!
18//! A project-root `.env` file is a **local-dev feeder for the highest layer**
19//! (the `AUTUMN_*` env-var layer) -- it does *not* add a new precedence tier.
20//! Values parsed from `.env` populate env-layer keys that are still unset; a
21//! real environment variable of the same name always wins. Auto-loaded in the
22//! `dev` and `test` profiles and ignored in `prod` unless `AUTUMN_DOTENV=1`.
23//! Files load in order `.env` -> `.env.local` -> `.env.{profile}` ->
24//! `.env.{profile}.local`, and earlier files (and real env vars) win. See the
25//! [`dotenv`](crate::dotenv) module.
26//!
27//! # Profiles
28//!
29//! Profiles are resolved in precedence order:
30//! 1. `AUTUMN_ENV` environment variable
31//! 2. `AUTUMN_PROFILE` environment variable (legacy alias)
32//! 3. `--profile` CLI flag
33//! 4. Auto-detect from debug/release build mode
34//!
35//! # Example
36//!
37//! ```rust
38//! use autumn_web::config::AutumnConfig;
39//!
40//! // All defaults -- no file needed
41//! let config = AutumnConfig::default();
42//! assert_eq!(config.server.port, 3000);
43//! assert_eq!(config.server.host, "127.0.0.1");
44//! assert!(config.database.url.is_none());
45//! ```
46//!
47//! # Environment variable reference
48//!
49//! | Variable | Config field | Type |
50//! |----------|-------------|------|
51//! | `AUTUMN_SERVER__PORT` | `server.port` | `u16` |
52//! | `AUTUMN_SERVER__HOST` | `server.host` | `String` |
53//! | `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` | `server.shutdown_timeout_secs` | `u64` |
54//! | `AUTUMN_SERVER__PRESTOP_GRACE_SECS` | `server.prestop_grace_secs` | `u64` |
55//! | `AUTUMN_SERVER__UPGRADE__ENABLED` | `server.upgrade.enabled` | `bool` |
56//! | `AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS` | `server.upgrade.ready_timeout_secs` | `u64` |
57//! | `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS` | `server.timeouts.request_timeout_ms` | `u64` |
58//! | `AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER` | `server.timeouts.accept_deadline_header` | `bool` |
59//! | `AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS` | `server.http.header_read_timeout_ms` | `u64` |
60//! | `AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS` | `server.http.keep_alive_timeout_ms` | `u64` |
61//! | `AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES` | `server.http.max_header_bytes` | `usize` |
62//! | `AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS` | `server.http.http2_max_concurrent_streams` | `u32` |
63//! | `AUTUMN_SERVER__HTTP__MAX_CONNECTIONS` | `server.http.max_connections` | `usize` |
64//! | `AUTUMN_REALTIME__MAX_CONNECTIONS` | `realtime.max_connections` | `usize` |
65//! | `AUTUMN_REALTIME__MAX_MESSAGE_BYTES` | `realtime.max_message_bytes` | `usize` |
66//! | `AUTUMN_REALTIME__PING_INTERVAL_MS` | `realtime.ping_interval_ms` | `u64` |
67//! | `AUTUMN_REALTIME__IDLE_TIMEOUT_MS` | `realtime.idle_timeout_ms` | `u64` |
68//! | `AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS` | `server.max_concurrent_requests` | `usize` |
69//! | `AUTUMN_SERVER__CAPACITY_CONTRACT` | `server.capacity_contract` | `String` |
70//! | `AUTUMN_SERVER__STRICT_CONFIG` | `server.strict_config` | `bool` |
71//! | `AUTUMN_DATABASE__URL` | `database.url` | `String` |
72//! | `AUTUMN_DATABASE__PRIMARY_URL` | `database.primary_url` | `String` |
73//! | `AUTUMN_DATABASE__REPLICA_URL` | `database.replica_url` | `String` |
74//! | `AUTUMN_DATABASE__POOL_SIZE` | `database.pool_size` | `usize` |
75//! | `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` | `database.primary_pool_size` | `usize` |
76//! | `AUTUMN_DATABASE__REPLICA_POOL_SIZE` | `database.replica_pool_size` | `usize` |
77//! | `AUTUMN_DATABASE__REPLICA_FALLBACK` | `database.replica_fallback` | `fail_readiness` / `primary` |
78//! | `AUTUMN_DATABASE__REPLICA_MAX_LAG_MS` | `database.replica_max_lag_ms` | `u64` |
79//! | `AUTUMN_DATABASE__WARN_ON_POOLER` | `database.warn_on_pooler` | `bool` |
80//! | `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` | `database.connect_timeout_secs` | `u64` |
81//! | `AUTUMN_DATABASE__STARTUP_WAIT_SECS` | `database.startup_wait_secs` | `u64` |
82//! | `AUTUMN_DATABASE__AUTO_MIGRATE` | `database.auto_migrate` | `Option<bool>` |
83//! | `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` | `database.auto_migrate_in_production` | `bool` |
84//! | `AUTUMN_DATABASE__STATEMENT_TIMEOUT` | `database.statement_timeout` | duration (`"30s"`, ms); empty clears |
85//! | `AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT` | `database.idle_in_transaction_timeout` | duration; empty clears |
86//! | `AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT` | `database.migration_lock_timeout` | duration; empty is ignored |
87//! | `AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES` | `database.migration_lock_retries` | `u32` |
88//! | `AUTUMN_DATABASE__FLEET__MODE` | `database.fleet.mode` | `tenant` / `slot` |
89//! | `AUTUMN_DATABASE__FLEET__ROOT` | `database.fleet.root` | `String` |
90//! | `AUTUMN_DATABASE__FLEET__PATH` | `database.fleet.path` | `String` |
91//! | `AUTUMN_DATABASE__FLEET__MAX_OPEN` | `database.fleet.max_open` | `usize` |
92//! | `AUTUMN_DATABASE__FLEET__POOL_SIZE` | `database.fleet.pool_size` | `usize` |
93//! | `AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND` | `database.fleet.create_on_demand` | `Option<bool>` |
94//! | `AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS` | `database.fleet.idle_close_secs` | `u64` |
95//! | `AUTUMN_DATABASE__FLEET__RESTORE_MISSING` | `database.fleet.restore_missing` | `bool` |
96//! | `AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER` | `database.fleet.ownership.owner` | `String` (creates the section) |
97//! | `AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS` | `database.fleet.ownership.slots` | comma-separated `N` / `A-B` |
98//! | `AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS` | `database.fleet.ownership.lease_secs` | `u64` |
99//! | `AUTUMN_DATABASE__SHARDS__{i}__NAME` | `database.shards[i].name` | `String` |
100//! | `AUTUMN_DATABASE__SHARDS__{i}__PRIMARY_URL` | `database.shards[i].primary_url` | `String` |
101//! | `AUTUMN_DATABASE__SHARDS__{i}__SLOTS` | `database.shards[i].slots` | CSV of indices / `A-B` ranges |
102//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_URL` | `database.shards[i].replica_url` | `String` |
103//! | `AUTUMN_DATABASE__SHARDS__{i}__PRIMARY_POOL_SIZE` | `database.shards[i].primary_pool_size` | `usize` |
104//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_POOL_SIZE` | `database.shards[i].replica_pool_size` | `usize` |
105//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_FALLBACK` | `database.shards[i].replica_fallback` | `fail_readiness` / `primary` |
106//! | `AUTUMN_LOG__LEVEL` | `log.level` | tracing filter directive |
107//! | `AUTUMN_LOG__FORMAT` | `log.format` | `Auto` / `Pretty` / `Json` |
108//! | `AUTUMN_TELEMETRY__ENABLED` | `telemetry.enabled` | `bool` |
109//! | `AUTUMN_TELEMETRY__SERVICE_NAME` | `telemetry.service_name` | `String` |
110//! | `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` | `telemetry.service_namespace` | `String` |
111//! | `AUTUMN_TELEMETRY__SERVICE_VERSION` | `telemetry.service_version` | `String` |
112//! | `AUTUMN_TELEMETRY__ENVIRONMENT` | `telemetry.environment` | `String` |
113//! | `AUTUMN_TELEMETRY__OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | `String` |
114//! | `AUTUMN_TELEMETRY__PROTOCOL` | `telemetry.protocol` | `Grpc` / `HttpProtobuf` |
115//! | `AUTUMN_TELEMETRY__STRICT` | `telemetry.strict` | `bool` |
116//! | `AUTUMN_TELEMETRY__SAMPLE_RATIO` | `telemetry.sample_ratio` | `f64` |
117//! | `AUTUMN_METRICS__MAX_SERIES_PER_METRIC` | `metrics.max_series_per_metric` | `usize` |
118//! | `AUTUMN_METRICS__MAX_INSTRUMENTS` | `metrics.max_instruments` | `usize` |
119//! | `AUTUMN_METRICS__MAX_LABELS_PER_SERIES` | `metrics.max_labels_per_series` | `usize` |
120//! | `AUTUMN_HEALTH__PATH` | `health.path` | `String` |
121//! | `AUTUMN_HEALTH__LIVE_PATH` | `health.live_path` | `String` |
122//! | `AUTUMN_HEALTH__READY_PATH` | `health.ready_path` | `String` |
123//! | `AUTUMN_HEALTH__STARTUP_PATH` | `health.startup_path` | `String` |
124//! | `AUTUMN_HEALTH__DETAILED` | `health.detailed` | `bool` |
125//! | `AUTUMN_HEALTH__ENABLED` | `health.enabled` | `bool` |
126//! | `AUTUMN_HEALTH__CACHE_TTL_MS` | `health.cache_ttl_ms` | `u64` |
127//! | `AUTUMN_HEALTH__PING_TIMEOUT_MS` | `health.ping_timeout_ms` | `u64` |
128//! | `AUTUMN_HEALTH__REDIS_READINESS` | `health.redis_readiness` | `bool` |
129//! | `AUTUMN_HEALTH__DB_READINESS` | `health.db_readiness` | `bool` |
130//! | `AUTUMN_CORS__ALLOWED_ORIGINS` | `cors.allowed_origins` | comma-separated `String` |
131//! | `AUTUMN_CORS__ALLOWED_METHODS` | `cors.allowed_methods` | comma-separated `String` |
132//! | `AUTUMN_CORS__ALLOWED_HEADERS` | `cors.allowed_headers` | comma-separated `String` |
133//! | `AUTUMN_CORS__ALLOW_CREDENTIALS` | `cors.allow_credentials` | `bool` |
134//! | `AUTUMN_CORS__MAX_AGE_SECS` | `cors.max_age_secs` | `u64` |
135//! | `AUTUMN_CACHE__BACKEND` | `cache.backend` | `memory` / `redis` |
136//! | `AUTUMN_CACHE__REDIS__URL` | `cache.redis.url` | `String` |
137//! | `AUTUMN_CACHE__REDIS__KEY_PREFIX` | `cache.redis.key_prefix` | `String` |
138//! | `AUTUMN_SESSION__BACKEND` | `session.backend` | `memory` / `redis` |
139//! | `AUTUMN_SESSION__COOKIE_NAME` | `session.cookie_name` | `String` |
140//! | `AUTUMN_SESSION__MAX_AGE_SECS` | `session.max_age_secs` | `u64` |
141//! | `AUTUMN_SESSION__SECURE` | `session.secure` | `bool` |
142//! | `AUTUMN_SESSION__SAME_SITE` | `session.same_site` | `String` |
143//! | `AUTUMN_SESSION__HTTP_ONLY` | `session.http_only` | `bool` |
144//! | `AUTUMN_SESSION__PATH` | `session.path` | `String` |
145//! | `AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION` | `session.allow_memory_in_production` | `bool` |
146//! | `AUTUMN_SESSION__REDIS__URL` | `session.redis.url` | `String` |
147//! | `AUTUMN_SESSION__REDIS__KEY_PREFIX` | `session.redis.key_prefix` | `String` |
148//! | `AUTUMN_CHANNELS__BACKEND` | `channels.backend` | `in_process` / `redis` |
149//! | `AUTUMN_CHANNELS__CAPACITY` | `channels.capacity` | `usize` |
150//! | `AUTUMN_CHANNELS__REPLAY_BUFFER` | `channels.replay_buffer` | `usize` |
151//! | `AUTUMN_CHANNELS__REDIS__URL` | `channels.redis.url` | `String` |
152//! | `AUTUMN_CHANNELS__REDIS__KEY_PREFIX` | `channels.redis.key_prefix` | `String` |
153//! | `AUTUMN_JOBS__BACKEND` | `jobs.backend` | `local` / `postgres` / `redis` / `sqlite` |
154//! | `AUTUMN_JOBS__SQLITE__VISIBILITY_TIMEOUT_MS` | `jobs.sqlite.visibility_timeout_ms` | `u64` |
155//! | `AUTUMN_JOBS__SQLITE__POLL_INTERVAL_MS` | `jobs.sqlite.poll_interval_ms` | `u64` |
156//! | `AUTUMN_JOBS__WORKERS` | `jobs.workers` | `usize` |
157//! | `AUTUMN_JOBS__PIN` | `jobs.pin` | comma-separated queue names |
158//! | `AUTUMN_JOBS__MAX_ATTEMPTS` | `jobs.max_attempts` | `u32` |
159//! | `AUTUMN_JOBS__INITIAL_BACKOFF_MS` | `jobs.initial_backoff_ms` | `u64` |
160//! | `AUTUMN_JOBS__MAX_BACKOFF_MS` | `jobs.max_backoff_ms` | `u64` |
161//! | `AUTUMN_JOBS__DEFAULT_TIMEOUT_MS` | `jobs.default_timeout_ms` | `u64` |
162//! | `AUTUMN_JOBS__REDIS__URL` | `jobs.redis.url` | `String` |
163//! | `AUTUMN_JOBS__REDIS__KEY_PREFIX` | `jobs.redis.key_prefix` | `String` |
164//! | `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` | `jobs.redis.visibility_timeout_ms` | `u64` |
165//! | `AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT` | `jobs.redis.dead_letter_limit` | `usize` (`0` = unbounded) |
166//! | `AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS` | `jobs.postgres.visibility_timeout_ms` | `u64` |
167//! | `AUTUMN_JOBS__POSTGRES__SHARD_LOCAL` | `jobs.postgres.shard_local` | `bool` |
168//! | `AUTUMN_JOBS__TENANTS__MAX_CONCURRENT` | `jobs.tenants.max_concurrent` | `usize` (`0` = no limit) |
169//! | `AUTUMN_JOBS__TENANTS__LANES` | `jobs.tenants.lanes` | `u16` (`0` = off) |
170//! | `AUTUMN_JOBS__TENANTS__LANES_PER_TENANT` | `jobs.tenants.lanes_per_tenant` | `u16` |
171//! | `AUTUMN_JOBS__TRACKING__TTL_SECS` | `jobs.tracking.ttl_secs` | `u64` |
172//! | `AUTUMN_JOBS__TRACKING__ROUTE_ENABLED` | `jobs.tracking.route_enabled` | `bool` |
173//! | `AUTUMN_OUTBOX__ENABLED` | `outbox.enabled` | `bool` |
174//! | `AUTUMN_OUTBOX__POLL_INTERVAL_MS` | `outbox.poll_interval_ms` | `u64` |
175//! | `AUTUMN_OUTBOX__BATCH_SIZE` | `outbox.batch_size` | `usize` |
176//! | `AUTUMN_OUTBOX__MAX_ATTEMPTS` | `outbox.max_attempts` | `u32` |
177//! | `AUTUMN_OUTBOX__INITIAL_BACKOFF_MS` | `outbox.initial_backoff_ms` | `u64` |
178//! | `AUTUMN_OUTBOX__MAX_BACKOFF_MS` | `outbox.max_backoff_ms` | `u64` |
179//! | `AUTUMN_OUTBOX__LEASE_MS` | `outbox.lease_ms` | `u64` |
180//! | `AUTUMN_OUTBOX__RETENTION_MS` | `outbox.retention_ms` | `u64` |
181//! | `AUTUMN_SCHEDULER__BACKEND` | `scheduler.backend` | `in_process` / `postgres` / `sqlite` |
182//! | `AUTUMN_RETENTION__SWEEP_INTERVAL` | `retention.sweep_interval` | duration `String` |
183//! | `AUTUMN_RETENTION__JOB_HISTORY` | `retention.job_history` | duration `String` |
184//! | `AUTUMN_RETENTION__COMMIT_HOOKS` | `retention.commit_hooks` | duration `String` |
185//! | `AUTUMN_RETENTION__JOB_TRACKING` | `retention.job_tracking` | duration `String` |
186//! | `AUTUMN_RETENTION__IDEMPOTENCY` | `retention.idempotency` | duration `String` |
187//! | `AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS` | `retention.experiment_assignments` | duration `String` |
188//! | `AUTUMN_RETENTION__WEBHOOK_REPLAY` | `retention.webhook_replay` | duration `String` |
189//! | `AUTUMN_RETENTION__SESSIONS` | `retention.sessions` | duration `String` |
190//! | `AUTUMN_RETENTION__AUDIT_ARCHIVES` | `retention.audit_archives` | duration `String` |
191//! | `AUTUMN_RETENTION__CUSTOM_DOMAINS` | `retention.custom_domains` | duration `String` |
192//! | `AUTUMN_SCHEDULER__LEASE_TTL_SECS` | `scheduler.lease_ttl_secs` | `u64` |
193//! | `AUTUMN_SCHEDULER__REPLICA_ID` | `scheduler.replica_id` | `String` |
194//! | `AUTUMN_SCHEDULER__KEY_PREFIX` | `scheduler.key_prefix` | `String` |
195//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
196//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
197//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
198//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
199//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
200//! | `AUTUMN_ENV` | active profile | `String` |
201//! | `AUTUMN_PROFILE` | active profile (legacy alias) | `String` |
202//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
203//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
204//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
205//! | `AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH` | `security.upload.reject_on_content_type_mismatch` | `bool` |
206//! | `AUTUMN_SECURITY__FORBIDDEN_RESPONSE` | `security.forbidden_response` | `"403"` or `"404"` |
207//! | `AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API` | `security.allow_unauthorized_repository_api` | `bool` |
208//! | `AUTUMN_SECURITY__SIGNING_SECRET` | `security.signing_secret.secret` | `String` |
209//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
210//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
211//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
212//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
213//! | `AUTUMN_DEV__INSPECTOR_PATH` | `dev.inspector_path` | `String` |
214//! | `AUTUMN_DEV__INSPECTOR_CAPACITY` | `dev.inspector_capacity` | `usize` |
215//! | `AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD` | `dev.inspector_n_plus_one_threshold` | `usize` |
216//! | `AUTUMN_OBSERVABILITY__SERVER_TIMING` | `observability.server_timing` | `bool` |
217//! | `AUTUMN_COST__ENABLED` | `cost.enabled` | `bool` |
218//! | `AUTUMN_COST__DEFER_THRESHOLD` | `cost.defer_threshold` | `f64` |
219//! | `AUTUMN_COST__DEFER_RECHECK_SECS` | `cost.defer_recheck_secs` | `u64` |
220//! | `AUTUMN_COST__MAX_TENANTS` | `cost.max_tenants` | `usize` |
221//! | `AUTUMN_COST__SIGNAL_REFRESH_SECS` | `cost.signal_refresh_secs` | `u64` |
222//! | `AUTUMN_COMPRESSION__ENABLED` | `compression.enabled` | `bool` |
223//! | `AUTUMN_STORIES__ENABLED` | `stories.enabled` | `bool` |
224//! | `AUTUMN_AUTH__LOCKOUT__ENABLED` | `auth.lockout.enabled` | `bool` |
225//! | `AUTUMN_AUTH__LOCKOUT__THRESHOLD` | `auth.lockout.threshold` | `i32` |
226//! | `AUTUMN_AUTH__LOCKOUT__WINDOW_SECS` | `auth.lockout.window_secs` | `u64` |
227//! | `AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS` | `auth.lockout.cooloff_secs` | `u64` |
228//! | `AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES` | `auth.magic_link.ttl_minutes` | `u64` |
229//! | `AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS` | `auth.magic_link.email_cooldown_secs` | `u64` |
230//! | `AUTUMN_TIME_ZONE__IDENTIFIER` | `time_zone.identifier` | IANA id `String` |
231//! | `AUTUMN_FAILURE_CAPTURE__ENABLED` | `failure_capture.enabled` | `bool` |
232//! | `AUTUMN_FAILURE_CAPTURE__DIR` | `failure_capture.dir` | `String` |
233//! | `AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES` | `failure_capture.max_body_bytes` | `usize` |
234//! | `AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES` | `failure_capture.max_capsule_bytes` | `usize` |
235//! | `AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES` | `failure_capture.max_capsules` | `usize` |
236//! | `AUTUMN_CLUSTER__ENABLED` | `cluster.enabled` | `bool` |
237//! | `AUTUMN_CLUSTER__SECRET` | `cluster.secret` | `SecretString` |
238//! | `AUTUMN_CLUSTER__CLUSTER_NAME` | `cluster.cluster_name` | `String` |
239//! | `AUTUMN_CLUSTER__BIND_ADDR` | `cluster.bind_addr` | `String` |
240//! | `AUTUMN_CLUSTER__ADVERTISE_ADDR` | `cluster.advertise_addr` | `String` |
241//! | `AUTUMN_CLUSTER__SEED_PEERS` | `cluster.seed_peers` | comma-separated addresses |
242//! | `AUTUMN_CLUSTER__NODE_ID` | `cluster.node_id` | `String` |
243//! | `AUTUMN_CLUSTER__PUSH_INTERVAL_MS` | `cluster.push_interval_ms` | `u64` |
244//! | `AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS` | `cluster.suspicion_timeout_ms` | `u64` |
245//! | `AUTUMN_SHADOW__ENABLED` | `shadow.enabled` | `bool` |
246//! | `AUTUMN_SHADOW__TARGET` | `shadow.target` | `String` |
247//! | `AUTUMN_SHADOW__SAMPLE_RATE` | `shadow.sample_rate` | `f64` |
248//! | `AUTUMN_SHADOW__ROUTES` | `shadow.routes` | comma-separated patterns |
249//! | `AUTUMN_SHADOW__TIMEOUT_MS` | `shadow.timeout_ms` | `u64` |
250//! | `AUTUMN_SHADOW__MAX_IN_FLIGHT` | `shadow.max_in_flight` | `usize` |
251//! | `AUTUMN_SHADOW__MAX_BODY_BYTES` | `shadow.max_body_bytes` | `usize` |
252//! | `AUTUMN_SHADOW__MAX_RECORDS` | `shadow.max_records` | `usize` |
253//! | `AUTUMN_SHADOW__MAX_SAMPLE_BYTES` | `shadow.max_sample_bytes` | `usize` |
254//! | `AUTUMN_FAULT_INJECTION__ENABLED` | `fault_injection.enabled` | `bool` |
255
256use std::path::{Path, PathBuf};
257
258use serde::Deserialize;
259use thiserror::Error;
260
261/// Abstraction for reading environment variables, supporting dependency injection for testing.
262use std::sync::OnceLock;
263
264static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
265static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
266
267#[doc(hidden)]
268pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
269    let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
270    let _ = MACRO_IS_DEBUG.set(is_debug);
271}
272
273/// Trait for environment variable reading to allow testing overrides.
274///
275/// This abstracts the OS environment (`std::env::var`) so that
276/// configuration loading logic can be unit-tested deterministically
277/// by supplying a mock environment.
278pub trait Env {
279    /// Read an environment variable.
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use autumn_web::config::{Env, OsEnv};
285    /// let env = OsEnv;
286    /// let val = env.var("NON_EXISTENT_VAR");
287    /// assert!(val.is_err());
288    /// ```
289    ///
290    /// # Errors
291    ///
292    /// Returns [`std::env::VarError`] if the variable is not present or is not valid Unicode.
293    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
294}
295
296/// Production implementation of `Env` that reads from the OS environment.
297#[derive(Clone, Default)]
298pub struct OsEnv;
299
300impl Env for OsEnv {
301    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
302        if key == "AUTUMN_MANIFEST_DIR" {
303            // Process env takes priority over the compile-time baked-in path so
304            // installed apps (e.g. Tauri sidecars) can redirect config loading to
305            // their bundled resource dir by setting AUTUMN_MANIFEST_DIR at launch.
306            if let Ok(override_val) = std::env::var(key) {
307                return Ok(override_val);
308            }
309            if let Some(dir) = MACRO_MANIFEST_DIR.get() {
310                return Ok(dir.clone());
311            }
312        } else if key == "AUTUMN_IS_DEBUG"
313            && let Some(is_debug) = MACRO_IS_DEBUG.get()
314        {
315            return Ok(if *is_debug {
316                "1".to_string()
317            } else {
318                "0".to_string()
319            });
320        }
321        std::env::var(key)
322    }
323}
324
325/// Mock implementation of `Env` for testing.
326#[derive(Clone, Default)]
327pub struct MockEnv {
328    vars: std::collections::HashMap<String, String>,
329}
330
331impl MockEnv {
332    /// Create a new, empty `MockEnv`.
333    #[must_use]
334    pub fn new() -> Self {
335        Self {
336            vars: std::collections::HashMap::new(),
337        }
338    }
339
340    /// Set an environment variable in the mock.
341    #[must_use]
342    pub fn with(mut self, key: &str, value: &str) -> Self {
343        self.vars.insert(key.to_owned(), value.to_owned());
344        self
345    }
346
347    /// Remove an environment variable from the mock.
348    #[must_use]
349    pub fn without(mut self, key: &str) -> Self {
350        self.vars.remove(key);
351        self
352    }
353}
354
355impl Env for MockEnv {
356    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
357        self.vars
358            .get(key)
359            .cloned()
360            .ok_or(std::env::VarError::NotPresent)
361    }
362}
363
364/// Locate a config file by checking the app's crate directory first, then CWD.
365fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
366    if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
367        let candidate = PathBuf::from(manifest_dir).join(filename);
368        if candidate.exists() {
369            return candidate;
370        }
371    }
372    PathBuf::from(filename)
373}
374
375/// Load a TOML file as a raw `toml::Value` table.
376/// Returns `Ok(None)` if the file doesn't exist.
377fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
378    match std::fs::read_to_string(path) {
379        Ok(contents) => {
380            let table = toml::from_str::<toml::Table>(&contents)?;
381            Ok(Some(toml::Value::Table(table)))
382        }
383        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
384        Err(e) => Err(ConfigError::Io(e)),
385    }
386}
387
388/// Resolve the active profile using the precedence chain.
389///
390/// 1. `AUTUMN_ENV` env var (highest priority)
391/// 2. `AUTUMN_PROFILE` env var (legacy alias)
392/// 3. `--profile <name>` CLI flag
393/// 4. Auto-detect from build mode (`AUTUMN_IS_DEBUG` set by `#[autumn_web::main]`)
394/// 5. Fallback to `dev`
395pub(crate) fn resolve_profile(env: &dyn Env) -> String {
396    let selected_profile_input = resolve_profile_input(env);
397    normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
398}
399
400/// Resolve the raw profile selector value (before normalization).
401///
402/// The env-var keys consulted here (`AUTUMN_ENV`, `AUTUMN_PROFILE`,
403/// `AUTUMN_IS_DEBUG`) are the profile *selectors*; they are deliberately
404/// excluded from the `.env` overlay (see [`crate::dotenv`]'s
405/// `PROFILE_SELECTOR_KEYS`) so a `.env` file cannot switch the active profile.
406/// Keep the two lists in sync.
407fn resolve_profile_input(env: &dyn Env) -> String {
408    // 1. Preferred env var
409    if let Ok(profile) = env.var("AUTUMN_ENV") {
410        let trimmed = profile.trim();
411        if !trimmed.is_empty() {
412            return trimmed.to_owned();
413        }
414    }
415
416    // 2. Legacy env var
417    if let Ok(profile) = env.var("AUTUMN_PROFILE") {
418        let trimmed = profile.trim();
419        if !trimmed.is_empty() {
420            return trimmed.to_owned();
421        }
422    }
423
424    // 3. CLI flag
425    let args: Vec<String> = std::env::args().collect();
426    for (i, arg) in args.iter().enumerate() {
427        if arg == "--profile"
428            && let Some(profile) = args.get(i + 1)
429        {
430            let trimmed = profile.trim();
431            if !trimmed.is_empty() {
432                return trimmed.to_owned();
433            }
434        }
435        if let Some(profile) = arg.strip_prefix("--profile=") {
436            let trimmed = profile.trim();
437            if !trimmed.is_empty() {
438                return trimmed.to_owned();
439            }
440        }
441    }
442
443    // 4. Auto-detect from build mode
444    if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
445        return "prod".to_owned();
446    }
447    "dev".to_owned()
448}
449
450/// Normalize profile aliases and trim whitespace.
451///
452/// Supported aliases:
453/// - `production` -> `prod`
454/// - `development` -> `dev`
455/// - `prod`/`PROD` -> `prod`
456/// - `dev`/`DEV` -> `dev`
457///
458/// `pub` so the deploy CLI (`autumn-cli`) can mirror the runtime's profile
459/// normalization exactly when picking which local `autumn-<profile>.toml` to
460/// upload — a single source of truth prevents deploy/runtime drift (#1952).
461#[must_use]
462pub fn normalize_profile_name(profile: &str) -> Option<String> {
463    let trimmed = profile.trim();
464    if trimmed.is_empty() {
465        return None;
466    }
467
468    if trimmed.eq_ignore_ascii_case("production") {
469        return Some("prod".to_owned());
470    }
471    if trimmed.eq_ignore_ascii_case("development") {
472        return Some("dev".to_owned());
473    }
474    if trimmed.eq_ignore_ascii_case("prod") {
475        return Some("prod".to_owned());
476    }
477    if trimmed.eq_ignore_ascii_case("dev") {
478        return Some("dev".to_owned());
479    }
480
481    // Preserve user-specified case for custom profile names.
482    Some(trimmed.to_owned())
483}
484
485/// The single source of truth for "is this profile dev-like?", used by every
486/// dev-only surface (the request inspector, the HTML dev error overlay, …).
487///
488/// Fails closed: an unset profile (`None`) is NOT dev. A [`ConfigLoader`]
489/// other than the default [`TomlEnvConfigLoader`] is free to build an
490/// [`AutumnConfig`] however it wants (see `docs/guide/custom-subsystems.md`),
491/// and nothing requires it to populate `profile` — `resolve_profile` (which
492/// always resolves to a concrete profile, defaulting to `"dev"`) only runs
493/// inside `TomlEnvConfigLoader`. A dev-only surface must never treat that
494/// absence as an invitation to guess `cfg!(debug_assertions)` instead: a
495/// production deployment running a debug build (forgetting `--release`, or a
496/// misconfigured Docker multi-stage build, is an easy operational mistake)
497/// would otherwise get the dev error overlay — internal error messages,
498/// headers, cookies, and SQL query text — on every 5xx, even though the app
499/// author never opted into `profile = "dev"`.
500///
501/// [`ConfigLoader`]: crate::config::ConfigLoader
502#[must_use]
503pub(crate) fn profile_is_dev(profile: Option<&str>) -> bool {
504    matches!(profile, Some("dev" | "development"))
505}
506
507/// Profile names to check for inline/file overrides.
508///
509/// For canonical profiles, include legacy aliases for compatibility so
510/// `production` and `development` profile sources are still loaded.
511fn profile_lookup_names(profile: &str) -> Vec<&str> {
512    match profile {
513        "prod" => vec!["production", "prod"],
514        "dev" => vec!["development", "dev"],
515        other => vec![other],
516    }
517}
518
519/// Ordered file lookup names for profile override file compatibility.
520///
521/// Only one profile override file is loaded: the first existing file in this
522/// ordered list. The order prefers the explicitly-selected spelling.
523///
524/// `pub` so the deploy CLI (`autumn-cli`) can mirror the runtime's
525/// override-file lookup exactly when picking which local `autumn-<profile>.toml`
526/// to upload — a single source of truth prevents deploy/runtime drift (#1952).
527#[must_use]
528pub fn profile_override_file_lookup_names(
529    profile: &str,
530    selected_profile_input: &str,
531) -> Vec<String> {
532    match profile {
533        "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
534            vec!["production".to_owned(), "prod".to_owned()]
535        }
536        "prod" => vec!["prod".to_owned(), "production".to_owned()],
537        "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
538            vec!["development".to_owned(), "dev".to_owned()]
539        }
540        "dev" => vec!["dev".to_owned(), "development".to_owned()],
541        other => vec![other.to_owned()],
542    }
543}
544
545/// Extract `[profile.<name>]` table from a parsed `autumn.toml`.
546fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
547    base.get("profile")
548        .and_then(toml::Value::as_table)
549        .and_then(|profiles| profiles.get(profile))
550        .and_then(toml::Value::as_table)
551        .map(|table| toml::Value::Table(table.clone()))
552}
553
554/// `(prestop_grace_secs, shutdown_timeout_secs)` the runtime uses for `profile`
555/// when no config file or environment variable overrides them.
556///
557/// Read from the same table the loader uses. A profile without smart defaults
558/// gets the struct defaults.
559#[must_use]
560pub fn profile_shutdown_defaults(profile: &str) -> (u64, u64) {
561    let name = normalize_profile_name(profile).unwrap_or_else(|| profile.trim().to_owned());
562    let defaults = profile_defaults_as_toml(&name);
563    let read = |key: &str, fallback: u64| {
564        defaults
565            .get("server")
566            .and_then(|server| server.get(key))
567            .and_then(toml::Value::as_integer)
568            .and_then(|value| u64::try_from(value).ok())
569            .unwrap_or(fallback)
570    };
571    (
572        read("prestop_grace_secs", default_prestop_grace()),
573        read("shutdown_timeout_secs", default_shutdown_timeout()),
574    )
575}
576
577/// Profile-specific smart defaults as a TOML table.
578///
579/// Only `dev` and `prod` have smart defaults. Custom profiles
580/// (staging, test, etc.) get no smart defaults — they rely on
581/// their profile TOML file and env overrides.
582fn profile_defaults_as_toml(profile: &str) -> toml::Value {
583    match profile {
584        "dev" => toml::from_str(
585            r#"
586[log]
587level = "debug"
588format = "Pretty"
589
590[telemetry]
591environment = "development"
592
593[server]
594host = "127.0.0.1"
595shutdown_timeout_secs = 1
596# Zero-out the prestop grace in dev: there is no load balancer to
597# deregister, so the 5-second default would add unnecessary latency
598# on every Ctrl-C.
599prestop_grace_secs = 0
600
601[health]
602detailed = true
603
604[actuator]
605sensitive = true
606
607[cors]
608allowed_origins = ["*"]
609
610# Dev: enable the local-disk blob store rooted at
611# `target/blobs/` automatically when the `storage` feature
612# is on. `prod` deliberately leaves `backend = "disabled"`
613# so the operator has to opt into either `local` (with
614# `allow_local_in_production = true`) or `s3`.
615[storage]
616backend = "local"
617
618[security.trusted_proxies]
619# Dev: trust X-Forwarded-* from loopback only so local reverse
620# proxies (nginx, caddy, etc. on 127.0.0.1/::1) work out of the box.
621trust_forwarded_headers = true
622ranges = ["127.0.0.0/8", "::1/128"]
623# Dev: CSRF disabled (default), HSTS off (default)
624"#,
625        )
626        .expect("valid dev toml"),
627        "prod" => {
628            #[cfg_attr(
629                feature = "sqlite",
630                allow(unused_mut, reason = "only the Postgres build adds to it")
631            )]
632            let mut prod: toml::Value = toml::from_str(
633                r#"
634[log]
635level = "info"
636format = "Json"
637
638[telemetry]
639environment = "production"
640
641[server]
642host = "0.0.0.0"
643# Request timeout + 5 s margin (issue #3058).
644shutdown_timeout_secs = 35
645# Prod: a misspelled key fails the boot (#3057).
646strict_config = true
647
648[server.timeouts]
649request_timeout_ms = 30_000
650
651# Prod: bound slow and idle clients (issue #3065).
652[server.http]
653header_read_timeout_ms = 10_000
654keep_alive_timeout_ms = 75_000
655max_header_bytes = 65_536
656http2_max_concurrent_streams = 100
657max_connections = 10_000
658
659[realtime]
660max_message_bytes = 1_048_576
661ping_interval_ms = 30_000
662idle_timeout_ms = 120_000
663
664[health]
665detailed = false
666
667# Prod: strict security -- HSTS on, CSRF enabled, secure cookies
668[security.headers]
669strict_transport_security = true
670
671[security.csrf]
672enabled = true
673
674[session]
675secure = true
676"#,
677            )
678            .expect("valid prod toml");
679            // SQLite cannot enforce these, and it refuses to boot with a
680            // `statement_timeout` set, so they are Postgres-only defaults.
681            #[cfg(not(feature = "sqlite"))]
682            deep_merge(
683                &mut prod,
684                toml::from_str(
685                    r#"
686[database]
687statement_timeout = "30s"
688idle_in_transaction_timeout = "60s"
689"#,
690                )
691                .expect("valid prod database toml"),
692            );
693            prod
694        }
695        _ => toml::Value::Table(toml::map::Map::new()), // Custom profiles get no smart defaults
696    }
697}
698
699#[cfg(feature = "mail")]
700fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
701    merged
702        .get("mail")
703        .and_then(toml::Value::as_table)
704        .is_some_and(|mail| mail.contains_key("transport"))
705        || env
706            .var("AUTUMN_MAIL__TRANSPORT")
707            .ok()
708            .as_deref()
709            .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
710}
711
712/// Maximum recursion depth for merging TOML tables.
713const MAX_MERGE_DEPTH: usize = 16;
714
715/// Deep-merge two TOML values. Tables are merged recursively;
716/// non-table values in `overlay` replace those in `base`.
717fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
718    deep_merge_with_depth(base, overlay, 0);
719}
720
721fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
722    if depth > MAX_MERGE_DEPTH {
723        eprintln!(
724            "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
725        );
726        return;
727    }
728
729    let toml::Value::Table(overlay_table) = overlay else {
730        return;
731    };
732    let Some(base_table) = base.as_table_mut() else {
733        return;
734    };
735
736    for (key, overlay_val) in overlay_table {
737        let is_recursive_merge =
738            overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
739
740        if is_recursive_merge {
741            if let Some(base_val) = base_table.get_mut(&key) {
742                deep_merge_with_depth(base_val, overlay_val, depth + 1);
743            }
744        } else {
745            base_table.insert(key, overlay_val);
746        }
747    }
748}
749
750/// Suggest a close match for a custom profile name.
751///
752/// Returns `Some(name)` when a known profile is within edit distance 2.
753fn suggest_profile(profile: &str) -> Option<&'static str> {
754    let known = ["dev", "prod"];
755    let mut suggestions: Vec<(&str, usize)> = known
756        .iter()
757        .map(|k| (*k, levenshtein(profile, k)))
758        .filter(|(_, d)| *d <= 2)
759        .collect();
760    suggestions.sort_by_key(|(_, d)| *d);
761    suggestions.first().map(|(name, _)| *name)
762}
763
764/// Warn when a custom profile has no TOML file, suggesting close matches.
765fn warn_profile_typo(profile: &str) {
766    if let Some(suggestion) = suggest_profile(profile) {
767        eprintln!(
768            "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
769             and no smart defaults. Did you mean \"{suggestion}\"?"
770        );
771    }
772}
773
774fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
775    profile != "dev" && profile != "prod" && !has_inline_profile_section
776}
777
778/// Levenshtein edit distance between two strings.
779///
780/// ⚡ Bolt Optimization:
781/// Reduces memory allocations by using a single `Vec` instead of two and
782/// iterating directly over `Chars` to avoid `Vec<char>` allocations.
783#[must_use]
784pub fn levenshtein(a: &str, b: &str) -> usize {
785    let n = b.chars().count();
786    let mut prev: Vec<usize> = (0..=n).collect();
787    for (i, a_ch) in a.chars().enumerate() {
788        let mut prev_diag = prev[0];
789        prev[0] = i + 1;
790        for (j, b_ch) in b.chars().enumerate() {
791            let old_prev = prev[j + 1];
792            let cost = usize::from(a_ch != b_ch);
793            prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
794            prev_diag = old_prev;
795        }
796    }
797    prev[n]
798}
799
800// ── Deprecation channel ───────────────────────────────────────────────────────
801
802/// A configuration key (or its corresponding `AUTUMN_*` env var) that is
803/// deprecated but still honored for the current minor-release line.
804///
805/// Register entries in [`DEPRECATED_CONFIG_KEYS`]. The config loader emits a
806/// structured `WARN` for each entry whose key is present in the resolved config,
807/// and `autumn doctor` surfaces them as ⚠️ checks.
808///
809/// # Env-var contract
810///
811/// A registered `path` MUST correspond to the mechanical env-var name produced
812/// by [`deprecated_env_var_name`] (`a.b.c` → `AUTUMN_A__B__C`), which is the
813/// same name the loader's `apply_*_env_overrides` reads to honor the value. If
814/// a key's loader override uses a non-mechanical env-var name, env-var detection
815/// here would diverge from what the loader actually applies. The integration
816/// tests in `autumn/tests/config_deprecation.rs` lock this for every entry by
817/// loading config with each key set via its env var and asserting the value is
818/// honored.
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub struct DeprecatedKey {
821    /// Dotted config path, e.g. `"security.rate_limit.trusted_proxies"`.
822    pub path: &'static str,
823    /// The replacement key path, or `None` meaning "remove it; no replacement".
824    pub replacement: Option<&'static str>,
825    /// Version the deprecation was introduced (e.g. `"0.5.0"`).
826    pub since: &'static str,
827    /// Version the key is scheduled for removal (e.g. `"1.0.0"`).
828    pub remove_in: &'static str,
829}
830
831/// The canonical registry of deprecated config keys.
832///
833/// Add entries here when retiring a key; never silently delete a schema field
834/// without first registering it here. The schema-snapshot CI guard
835/// (`autumn/tests/schema_drift_guard.rs`) enforces this rule.
836pub static DEPRECATED_CONFIG_KEYS: &[DeprecatedKey] = &[
837    DeprecatedKey {
838        path: "security.rate_limit.trusted_proxies",
839        replacement: Some("security.trusted_proxies.ranges"),
840        since: "0.5.0",
841        remove_in: "1.0.0",
842    },
843    DeprecatedKey {
844        path: "security.rate_limit.trust_forwarded_headers",
845        replacement: Some("security.trusted_proxies.trust_forwarded_headers"),
846        since: "0.5.0",
847        remove_in: "1.0.0",
848    },
849];
850
851/// Returns the full registry of deprecated config keys.
852#[must_use]
853pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
854    DEPRECATED_CONFIG_KEYS
855}
856
857/// Converts a dotted config key path to its `AUTUMN_*` env var name.
858///
859/// # Examples
860/// ```
861/// # use autumn_web::config::deprecated_env_var_name;
862/// assert_eq!(
863///     deprecated_env_var_name("security.rate_limit.trusted_proxies"),
864///     "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
865/// );
866/// ```
867#[must_use]
868pub fn deprecated_env_var_name(path: &str) -> String {
869    format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
870}
871
872/// Where a deprecated key was detected: TOML only, env-var only, or both.
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum DeprecationSource {
875    Toml,
876    Env,
877    Both,
878}
879
880/// One detected use of a deprecated config key.
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct DeprecationFinding {
883    pub path: String,
884    pub replacement: Option<String>,
885    pub since: String,
886    pub remove_in: String,
887    pub source: DeprecationSource,
888}
889
890/// Tests whether a dotted key path is present in a TOML table (any value type).
891///
892/// Non-table mid-segments are treated as absent (no panic).
893fn toml_path_present(table: &toml::Table, path: &str) -> bool {
894    let mut current_table = table;
895    let mut segments = path.split('.').peekable();
896
897    while let Some(segment) = segments.next() {
898        if segments.peek().is_none() {
899            return current_table.contains_key(segment);
900        }
901        match current_table.get(segment) {
902            Some(toml::Value::Table(next)) => current_table = next,
903            _ => return false,
904        }
905    }
906    false
907}
908
909/// Scans the merged config table and env for any registered deprecated key.
910///
911/// Returns at most one [`DeprecationFinding`] per registry entry (even if the key
912/// is set in both TOML and env, the two sources are collapsed into [`DeprecationSource::Both`]).
913/// Registry order is preserved for deterministic output.
914#[must_use]
915pub fn detect_deprecated_keys(
916    merged: &toml::Table,
917    env: &dyn Env,
918    registry: &[DeprecatedKey],
919) -> Vec<DeprecationFinding> {
920    let mut findings = Vec::new();
921    for entry in registry {
922        let in_toml = toml_path_present(merged, entry.path);
923        let env_name = deprecated_env_var_name(entry.path);
924        let in_env = env.var(&env_name).is_ok();
925
926        let source = match (in_toml, in_env) {
927            (false, false) => continue,
928            (true, false) => DeprecationSource::Toml,
929            (false, true) => DeprecationSource::Env,
930            (true, true) => DeprecationSource::Both,
931        };
932
933        findings.push(DeprecationFinding {
934            path: entry.path.to_owned(),
935            replacement: entry.replacement.map(str::to_owned),
936            since: entry.since.to_owned(),
937            remove_in: entry.remove_in.to_owned(),
938            source,
939        });
940    }
941    findings
942}
943
944/// Detects deprecated keys the way [`AutumnConfig::load_with_env`] would, given a
945/// profile and a file-merged TOML table a tool has already built.
946///
947/// Seeds `profile_defaults_as_toml` as the base layer and deep-merges
948/// `file_table` on top before running [`detect_deprecated_keys`], so external
949/// tools (e.g. `autumn doctor`) evaluate the *same* layered config the runtime
950/// loader does — a key set only in a profile default is still detected.
951#[must_use]
952pub fn detect_deprecated_keys_for(
953    profile: &str,
954    file_table: &toml::Table,
955    env: &dyn Env,
956    registry: &[DeprecatedKey],
957) -> Vec<DeprecationFinding> {
958    let mut merged = profile_defaults_as_toml(profile);
959    deep_merge(&mut merged, toml::Value::Table(file_table.clone()));
960    let empty_table = toml::Table::new();
961    let merged_table = merged.as_table().unwrap_or(&empty_table);
962    detect_deprecated_keys(merged_table, env, registry)
963}
964
965/// Errors that can occur when loading or validating configuration.
966///
967/// Returned by [`AutumnConfig::load`], [`AutumnConfig::load_from`], and
968/// [`DatabaseConfig::validate`].
969///
970/// # Examples
971///
972/// ```rust
973/// use autumn_web::config::{AutumnConfig, ConfigError};
974/// use std::path::Path;
975///
976/// let result = AutumnConfig::load_from(Path::new("nonexistent.toml"));
977/// // Returns Ok(defaults) when file is missing -- not an error
978/// assert!(result.is_ok());
979/// ```
980#[derive(Debug, Error)]
981#[non_exhaustive]
982pub enum ConfigError {
983    /// The config file exists but could not be read.
984    #[error("failed to read autumn.toml: {0}")]
985    Io(#[from] std::io::Error),
986
987    /// The config file contains invalid TOML syntax.
988    #[error("invalid autumn.toml: {0}")]
989    Parse(#[from] toml::de::Error),
990
991    /// A configuration value failed semantic validation (e.g., invalid
992    /// database URL scheme).
993    #[error("configuration error: {0}")]
994    Validation(String),
995
996    /// The credentials file exists but could not be decrypted.
997    #[error("credentials error: {0}")]
998    Credentials(String),
999
1000    /// A project-root `.env` file exists but could not be read or parsed.
1001    #[error("dotenv error: {0}")]
1002    Dotenv(String),
1003}
1004
1005/// Top-level framework configuration.
1006///
1007/// All sections are optional -- missing sections use their defaults.
1008/// Deserialized from `autumn.toml` (TOML format).
1009///
1010/// # `autumn.toml` example
1011///
1012/// ```toml
1013/// [server]
1014/// port = 8080
1015///
1016/// [database]
1017/// url = "postgres://user:pass@db:5432/myapp"
1018/// pool_size = 20
1019/// ```
1020///
1021/// # Examples
1022///
1023/// ```rust
1024/// use autumn_web::config::AutumnConfig;
1025///
1026/// let config = AutumnConfig::default();
1027/// assert_eq!(config.server.port, 3000);
1028/// assert_eq!(config.database.pool_size, 10);
1029/// assert_eq!(config.log.level, "info");
1030/// assert_eq!(config.health.path, "/health");
1031/// ```
1032/// `[backup]` configuration section (issue #1619).
1033///
1034/// Groups database-backup destinations. Currently only an offsite S3-compatible
1035/// destination is supported. NOT feature-gated: this section (`[backup.offsite]`)
1036/// is recognized by every autumn-web build so a strict-config app compiled
1037/// WITHOUT the `storage` feature still accepts its own `autumn.toml` `[backup]`
1038/// keys (the offsite upload client lives in the CLI and is independent of the
1039/// storage feature).
1040#[derive(Debug, Clone, Default, Deserialize)]
1041pub struct BackupConfig {
1042    /// Offsite upload destination (`[backup.offsite]`). `None` (the default)
1043    /// means no offsite destination is configured; `autumn db backup --upload`
1044    /// then errors with configuration guidance rather than silently no-op'ing.
1045    ///
1046    /// Boxed so an unconfigured `[backup]` costs one pointer rather than the full
1047    /// [`OffsiteBackupConfig`] inline: `AutumnConfig` is held across awaits in the
1048    /// app-run future, and keeping this field small avoids bloating that future.
1049    #[serde(default)]
1050    pub offsite: Option<Box<OffsiteBackupConfig>>,
1051}
1052
1053/// `[backup.offsite]` — an S3-compatible offsite backup destination (issue #1619).
1054///
1055/// Credentials are supplied by env-var *indirection* only: `s3.access_key_id_env`
1056/// / `s3.secret_access_key_env` name the environment variables the secrets are
1057/// read from at upload time. The secret values themselves never live in config,
1058/// argv, logs, or error messages.
1059#[derive(Debug, Clone, Default, Deserialize)]
1060pub struct OffsiteBackupConfig {
1061    /// S3-compatible connection + credential-indirection settings
1062    /// (`[backup.offsite.s3]`). Works against AWS S3 / `MinIO` / R2 / B2 / Garage.
1063    #[serde(default)]
1064    pub s3: OffsiteS3Config,
1065
1066    /// Key prefix under which run directories are stored. Defaults to `""`
1067    /// (bucket root). Objects are keyed `{prefix}/{profile}/{timestamp}/{file}`.
1068    #[serde(default)]
1069    pub prefix: Option<String>,
1070
1071    /// Independent remote retention: keep only the newest `N` uploaded runs per
1072    /// profile, pruning older ones *after* a verified upload. `None` (default)
1073    /// keeps all remote runs. Distinct from the local `--keep`.
1074    #[serde(default)]
1075    pub keep: Option<usize>,
1076
1077    /// Upload after every successful `autumn db backup` even without `--upload`
1078    /// (the "configured default" upload, AC #1). Off by default.
1079    #[serde(default)]
1080    pub auto_upload: bool,
1081
1082    /// Opt-in to pointing the offsite destination at the same bucket+endpoint as
1083    /// the app's user-facing blob storage (`[storage.s3]`). Off by default so a
1084    /// shared bucket is a deliberate choice (AC #3).
1085    #[serde(default)]
1086    pub allow_shared_bucket: bool,
1087}
1088
1089/// `[backup.offsite.s3]` — S3-compatible connection settings for offsite backups.
1090///
1091/// A dedicated, NON-feature-gated mirror of the storage backend's S3 shape so the
1092/// `[backup]` section is available in every autumn-web build (the storage
1093/// module — and its `StorageS3Config` — only exist under the `storage` feature).
1094/// Credentials are named via `*_env` indirection, never inlined.
1095#[derive(Debug, Clone, Default, Deserialize)]
1096pub struct OffsiteS3Config {
1097    /// Target bucket.
1098    #[serde(default)]
1099    pub bucket: Option<String>,
1100
1101    /// AWS region or region-shaped string (R2 uses `auto`). Used for the `SigV4`
1102    /// credential scope; many S3-compatible endpoints ignore it.
1103    #[serde(default)]
1104    pub region: Option<String>,
1105
1106    /// Custom endpoint URL. Required for non-AWS providers (R2, `MinIO`, B2,
1107    /// Garage). Leave unset for AWS.
1108    #[serde(default)]
1109    pub endpoint: Option<String>,
1110
1111    /// Environment variable the access-key id is read from.
1112    #[serde(default)]
1113    pub access_key_id_env: Option<String>,
1114
1115    /// Environment variable the secret access key is read from.
1116    #[serde(default)]
1117    pub secret_access_key_env: Option<String>,
1118
1119    /// Path-style addressing toggle (R2 / `MinIO` need this `true`).
1120    #[serde(default)]
1121    pub force_path_style: bool,
1122}
1123
1124/// `[replication]` — continuous `SQLite` replication to an offsite destination
1125/// (issue #1628).
1126///
1127/// NOT feature-gated, for the same reason `[backup]` is not: a strict-config app
1128/// must accept its own `autumn.toml` keys whatever feature set it was compiled
1129/// with. The replication engine itself lives behind the `db` feature.
1130///
1131/// The section is absent by default. Present-but-`enabled = false` is the shape
1132/// a profile overlay wants: keep one destination definition in `autumn.toml` and
1133/// turn replication off for `dev`/`test`.
1134///
1135/// ```toml
1136/// [replication]
1137/// enabled = true
1138/// rpo_secs = 10
1139/// retention_hours = 168
1140///
1141/// [replication.s3]
1142/// bucket = "myapp-replicas"
1143/// region = "auto"
1144/// endpoint = "https://<account>.r2.cloudflarestorage.com"
1145/// access_key_id_env = "AUTUMN_REPLICA_ACCESS_KEY_ID"
1146/// secret_access_key_env = "AUTUMN_REPLICA_SECRET_ACCESS_KEY"
1147/// force_path_style = true
1148/// ```
1149#[derive(Debug, Clone, Deserialize)]
1150pub struct ReplicationConfig {
1151    /// Master switch. Off by default, so adding the section is not enough to
1152    /// start shipping — the operator must say so.
1153    #[serde(default)]
1154    pub enabled: bool,
1155
1156    /// The recovery point objective in seconds: the steady-state upper bound on
1157    /// how much recently committed data a total loss of the machine costs.
1158    /// Defaults to 10 (AC #2).
1159    #[serde(default = "default_rpo_secs")]
1160    pub rpo_secs: u64,
1161
1162    /// How often the replicator ships, in seconds. Defaults to half the RPO
1163    /// (minimum one second) so the steady-state lag stays inside the contract.
1164    #[serde(default)]
1165    pub sync_interval_secs: Option<u64>,
1166
1167    /// How old a generation may get before the replicator checkpoints and opens
1168    /// a fresh one. Bounds how many segments a restore replays. Default: 1 hour.
1169    #[serde(default = "default_snapshot_interval_secs")]
1170    pub snapshot_interval_secs: u64,
1171
1172    /// How large the `-wal` file may grow before the replicator checkpoints.
1173    /// Default: 16 MiB.
1174    #[serde(default = "default_max_wal_bytes")]
1175    pub max_wal_bytes: u64,
1176
1177    /// How far back a point-in-time restore must stay possible. Older
1178    /// generations are pruned. Default: 168 hours (7 days).
1179    #[serde(default = "default_retention_hours")]
1180    pub retention_hours: u64,
1181
1182    /// How often to prove the replica restorable by actually restoring it into
1183    /// a scratch directory. `0` disables periodic verification. Default: 6 hours.
1184    #[serde(default = "default_verify_interval_secs")]
1185    pub verify_interval_secs: u64,
1186
1187    /// Key prefix under the destination. Objects are keyed
1188    /// `{prefix}/{profile}/generations/…`. Defaults to the bucket/directory root.
1189    #[serde(default)]
1190    pub prefix: Option<String>,
1191
1192    /// Opt in to pointing replication at the same bucket + endpoint as the app's
1193    /// user-facing blob storage (`[storage.s3]`). Off by default so a shared
1194    /// bucket — where a lifecycle rule written for user uploads could quietly
1195    /// expire the replicas — is a deliberate choice. Mirrors #1619.
1196    #[serde(default)]
1197    pub allow_shared_bucket: bool,
1198
1199    /// S3-compatible destination (`[replication.s3]`). Mutually exclusive with
1200    /// [`path`](Self::path).
1201    #[serde(default)]
1202    pub s3: Option<ReplicationS3Config>,
1203
1204    /// Filesystem destination: a directory to replicate into. A second disk, an
1205    /// NFS/SSHFS mount or a bind-mounted volume is a legitimate offsite target.
1206    /// Mutually exclusive with [`s3`](Self::s3).
1207    #[serde(default)]
1208    pub path: Option<String>,
1209}
1210
1211impl Default for ReplicationConfig {
1212    fn default() -> Self {
1213        Self {
1214            enabled: false,
1215            rpo_secs: default_rpo_secs(),
1216            sync_interval_secs: None,
1217            snapshot_interval_secs: default_snapshot_interval_secs(),
1218            max_wal_bytes: default_max_wal_bytes(),
1219            retention_hours: default_retention_hours(),
1220            verify_interval_secs: default_verify_interval_secs(),
1221            prefix: None,
1222            allow_shared_bucket: false,
1223            s3: None,
1224            path: None,
1225        }
1226    }
1227}
1228
1229impl ReplicationConfig {
1230    /// The effective ship interval: the explicit override, else half the RPO,
1231    /// never less than one second.
1232    #[must_use]
1233    pub fn sync_interval(&self) -> std::time::Duration {
1234        let secs = self
1235            .sync_interval_secs
1236            .unwrap_or_else(|| self.rpo_secs.div_euclid(2))
1237            .max(1);
1238        std::time::Duration::from_secs(secs)
1239    }
1240
1241    /// The RPO as a duration, never zero.
1242    #[must_use]
1243    pub fn rpo(&self) -> std::time::Duration {
1244        std::time::Duration::from_secs(self.rpo_secs.max(1))
1245    }
1246
1247    /// The periodic-verification interval, or `None` when disabled.
1248    #[must_use]
1249    pub fn verify_interval(&self) -> Option<std::time::Duration> {
1250        (self.verify_interval_secs > 0)
1251            .then(|| std::time::Duration::from_secs(self.verify_interval_secs))
1252    }
1253
1254    /// Validate the section on its own terms, returning an operator-facing
1255    /// message for each problem. Only meaningful when `enabled`.
1256    #[must_use]
1257    pub fn validation_errors(&self) -> Vec<String> {
1258        let mut errors = Vec::new();
1259        if !self.enabled {
1260            return errors;
1261        }
1262        match (&self.s3, &self.path) {
1263            (Some(_), Some(_)) => errors.push(
1264                "[replication] configures both `s3` and `path`; pick exactly one destination"
1265                    .to_owned(),
1266            ),
1267            (None, None) => errors.push(
1268                "[replication] enabled = true but no destination is configured; add a \
1269                 [replication.s3] section (bucket/region/endpoint plus *_env credential \
1270                 indirection) or set `path` to a directory"
1271                    .to_owned(),
1272            ),
1273            (Some(s3), None) => {
1274                if s3.bucket.as_deref().unwrap_or("").trim().is_empty() {
1275                    errors.push("[replication.s3] bucket is unset".to_owned());
1276                }
1277                if s3
1278                    .access_key_id_env
1279                    .as_deref()
1280                    .unwrap_or("")
1281                    .trim()
1282                    .is_empty()
1283                    || s3
1284                        .secret_access_key_env
1285                        .as_deref()
1286                        .unwrap_or("")
1287                        .trim()
1288                        .is_empty()
1289                {
1290                    errors.push(
1291                        "[replication.s3] needs access_key_id_env and secret_access_key_env \
1292                         (the NAMES of the environment variables the credentials are read \
1293                         from — never the credentials themselves)"
1294                            .to_owned(),
1295                    );
1296                }
1297            }
1298            (None, Some(path)) => {
1299                if path.trim().is_empty() {
1300                    errors.push("[replication] path is empty".to_owned());
1301                }
1302            }
1303        }
1304        if self.rpo_secs == 0 {
1305            errors.push("[replication] rpo_secs must be at least 1".to_owned());
1306        }
1307        // An explicit override longer than the RPO quietly invalidates the
1308        // objective it sits next to: `rpo_secs = 10` with
1309        // `sync_interval_secs = 60` ships once a minute and can lose nearly a
1310        // minute of committed writes, while every surface — the docs, the health
1311        // detail, the restore report — still promises ten seconds. Refuse the
1312        // pair rather than letting the stricter-looking number be the wrong one.
1313        if let Some(interval) = self.sync_interval_secs
1314            && self.rpo_secs > 0
1315            && interval > self.rpo_secs
1316        {
1317            errors.push(format!(
1318                "[replication] sync_interval_secs ({interval}) must not exceed rpo_secs \
1319                 ({}): shipping less often than the objective cannot meet it",
1320                self.rpo_secs
1321            ));
1322        }
1323        if self.retention_hours == 0 {
1324            errors.push("[replication] retention_hours must be at least 1".to_owned());
1325        }
1326        if self.max_wal_bytes < 32 {
1327            errors.push(
1328                "[replication] max_wal_bytes must be at least 32 (the size of a WAL header)"
1329                    .to_owned(),
1330            );
1331        }
1332        errors
1333    }
1334}
1335
1336/// `[replication.s3]` — S3-compatible destination for continuous replication.
1337///
1338/// Same credential posture as `[backup.offsite.s3]`: the secrets are named by
1339/// environment variable, never inlined into config, argv, logs, or errors.
1340#[derive(Debug, Clone, Default, Deserialize)]
1341pub struct ReplicationS3Config {
1342    /// Target bucket.
1343    #[serde(default)]
1344    pub bucket: Option<String>,
1345    /// Region for the `SigV4` credential scope (R2 uses `auto`).
1346    #[serde(default)]
1347    pub region: Option<String>,
1348    /// Custom endpoint URL. Required for non-AWS providers.
1349    #[serde(default)]
1350    pub endpoint: Option<String>,
1351    /// Environment variable the access-key id is read from.
1352    #[serde(default)]
1353    pub access_key_id_env: Option<String>,
1354    /// Environment variable the secret access key is read from.
1355    #[serde(default)]
1356    pub secret_access_key_env: Option<String>,
1357    /// Path-style addressing toggle (R2 / `MinIO` need this `true`).
1358    #[serde(default)]
1359    pub force_path_style: bool,
1360}
1361
1362/// Default RPO: at most ten seconds of potential data loss (#1628 AC #2).
1363const fn default_rpo_secs() -> u64 {
1364    10
1365}
1366
1367/// Default generation length: one hour.
1368const fn default_snapshot_interval_secs() -> u64 {
1369    3600
1370}
1371
1372/// Default WAL ceiling before a checkpoint: 16 MiB.
1373const fn default_max_wal_bytes() -> u64 {
1374    16 * 1024 * 1024
1375}
1376
1377/// Default point-in-time window: seven days.
1378const fn default_retention_hours() -> u64 {
1379    168
1380}
1381
1382/// Default restore-verification cadence: every six hours.
1383const fn default_verify_interval_secs() -> u64 {
1384    21_600
1385}
1386
1387#[derive(Debug, Clone, Default, Deserialize)]
1388pub struct AutumnConfig {
1389    /// Active profile name (e.g., "dev", "prod", "staging").
1390    /// Resolved at load time, not deserialized from TOML.
1391    #[serde(skip)]
1392    pub profile: Option<String>,
1393
1394    /// HTTP server settings (port, host, shutdown behavior).
1395    #[serde(default)]
1396    pub server: ServerConfig,
1397
1398    /// Push-button VPS deploy settings (`[deploy]` section, issue #1607).
1399    ///
1400    /// Operator-facing configuration for `autumn deploy` — the SSH-reachable
1401    /// target host plus the remote install layout and rollout tuning knobs.
1402    /// Top-level (not nested under `[server]`) because it describes *where and
1403    /// how* the app is deployed, not how the running server behaves.
1404    ///
1405    /// Absent by default (`None`), so an app that never runs `autumn deploy`
1406    /// is unaffected. A bare `[deploy]` table is valid at rest — `host` is only
1407    /// required when a deploy actually runs, enforced by
1408    /// [`DeployConfig::validate`].
1409    ///
1410    /// # Field ordering (load-bearing — do not move below `database`)
1411    ///
1412    /// `deploy` is declared here, before [`database`](Self::database), so that
1413    /// [`get_schema_keys`](Self::get_schema_keys)'s `SchemaDeserializer`
1414    /// traversal recurses into [`DeployConfig`]'s child keys and the strict
1415    /// unknown-key validator (`validate_toml` / `server.strict_config` /
1416    /// `autumn check --config`) rejects a typo like `[deploy] app_dr = "…"`.
1417    /// `DatabaseConfig` has a `deserialize_with` duration field
1418    /// (`statement_timeout`, via the untagged [`deserialize_duration`] parser)
1419    /// whose parser rejects the `SchemaDeserializer`'s placeholder value and
1420    /// returns an error, which aborts the remainder of `AutumnConfig`'s field
1421    /// traversal — so any section declared *after* `database` is recorded only
1422    /// as an opaque root leaf, never descended into. Keeping `deploy` ahead of
1423    /// `database` sidesteps that abort. The regression guard
1424    /// `deploy_child_keys_are_strictly_validated` fails if this ordering breaks.
1425    #[serde(default)]
1426    pub deploy: Option<DeployConfig>,
1427
1428    /// Service level objectives (`[[slo]]` tables, issue #3069).
1429    ///
1430    /// The app does not read them at run time. `autumn slo generate` and
1431    /// `autumn deploy` read them. See [`crate::slo`].
1432    ///
1433    /// Keep this field above `database`, for the same reason as `deploy`:
1434    /// strict validation then checks the keys in each table.
1435    #[serde(default)]
1436    pub slo: Vec<crate::slo::SloConfig>,
1437
1438    /// Deterministic replay capsule settings (`[failure_capture]` section,
1439    /// issue #1598).
1440    ///
1441    /// Off by default. When enabled, each failing request is written to disk
1442    /// as a replayable capsule; see [`FailureCaptureConfig`] and
1443    /// `docs/guide/failure-capsules.md` (capsules contain real request data —
1444    /// read the security section before turning this on).
1445    ///
1446    /// # Field ordering (load-bearing — do not move below `database`)
1447    ///
1448    /// Declared here, before [`database`](Self::database), for the same reason
1449    /// [`deploy`](Self::deploy) is: `DatabaseConfig`'s `deserialize_with`
1450    /// duration field aborts the `SchemaDeserializer` traversal, so a section
1451    /// declared after it is recorded only as an opaque root leaf and strict
1452    /// unknown-key validation never descends into its children. The regression
1453    /// guard `failure_capture_child_keys_are_strictly_validated` fails if this
1454    /// ordering breaks.
1455    #[cfg(feature = "reporting")]
1456    #[serde(default)]
1457    pub failure_capture: FailureCaptureConfig,
1458
1459    /// Embedded self-clustering control plane (`[cluster]` section, issue
1460    /// #1762).
1461    ///
1462    /// Off by default. See [`ClusterConfig`] and `docs/guide/clustering.md`.
1463    ///
1464    /// # Field ordering (load-bearing — do not move below `database`)
1465    ///
1466    /// Declared here, before [`database`](Self::database), for the same reason
1467    /// [`deploy`](Self::deploy) and `failure_capture` (a field only present
1468    /// with the `reporting` feature, hence not linked) are: `DatabaseConfig`'s
1469    /// `deserialize_with` duration field aborts the
1470    /// `SchemaDeserializer` traversal, so a section declared after it is
1471    /// recorded only as an opaque root leaf and strict unknown-key validation
1472    /// never descends into its children — a typo like
1473    /// `[cluster] seed_peer = […]` would then be silently accepted. The
1474    /// regression guard `cluster_child_keys_are_strictly_validated` fails if
1475    /// this ordering breaks.
1476    #[serde(default)]
1477    pub cluster: ClusterConfig,
1478
1479    /// Live-traffic shadow mirroring and response diffing (`[shadow]` section,
1480    /// issue #1653).
1481    ///
1482    /// Off by default. When enabled, a sampled slice of `GET`/`HEAD` traffic is
1483    /// replayed against an operator-provided candidate build and the two
1484    /// responses are diffed; see [`crate::shadow::ShadowConfig`] and
1485    /// `docs/guide/staged-deploys.md`.
1486    ///
1487    /// # Field ordering (load-bearing — do not move below `database`)
1488    ///
1489    /// Declared here, before [`database`](Self::database), for the same reason
1490    /// [`deploy`](Self::deploy) and `cluster` are: `DatabaseConfig`'s
1491    /// `deserialize_with` duration field can abort the `SchemaDeserializer`
1492    /// traversal, and a section it never descends into is recorded only as an
1493    /// opaque root leaf — so a typo like `[shadow] targt = "…"` would be
1494    /// silently accepted and the mirror would never run. The regression guard
1495    /// `shadow_child_keys_are_strictly_validated` fails if this ordering breaks.
1496    #[serde(default)]
1497    pub shadow: crate::shadow::ShadowConfig,
1498
1499    /// App-metric registry limits (`[metrics]` section).
1500    ///
1501    /// The cardinality caps the call-site metrics facade
1502    /// ([`crate::metrics`], #1378) enforces on an app's own instruments.
1503    /// Every key defaults to the value the facade shipped with, so an app
1504    /// with no `[metrics]` section behaves exactly as before.
1505    ///
1506    /// # Field ordering (load-bearing — do not move below `database`)
1507    ///
1508    /// Declared here, before [`database`](Self::database), for the same reason
1509    /// [`deploy`](Self::deploy), `cluster` and [`shadow`](Self::shadow) are:
1510    /// `DatabaseConfig`'s `deserialize_with` duration field aborts the
1511    /// `SchemaDeserializer` traversal, so a section declared after it is
1512    /// recorded only as an opaque root leaf and strict unknown-key validation
1513    /// never descends into its children — a typo like
1514    /// `[metrics] max_serie_per_metric = 500` would then be silently accepted
1515    /// and the cap the operator meant to raise would stay at its default. The
1516    /// regression guard `metrics_child_keys_are_strictly_validated` fails if
1517    /// this ordering breaks.
1518    #[serde(default)]
1519    pub metrics: MetricsConfig,
1520
1521    /// Staging fault injection (`[fault_injection]` section, issue #3071).
1522    ///
1523    /// Off by default. Refused in `prod` unless `allow_in_production = true`.
1524    /// See [`crate::fault_injection`] and `docs/guide/fault-injection.md`.
1525    ///
1526    /// Keep this field above `database`, for the same reason as `shadow`:
1527    /// strict validation then checks the keys. The regression guard is
1528    /// `fault_injection_child_keys_are_strictly_validated`.
1529    #[serde(default)]
1530    pub fault_injection: crate::fault_injection::FaultInjectionConfig,
1531
1532    /// Database connection settings (URL, pool size, timeouts).
1533    #[serde(default)]
1534    pub database: DatabaseConfig,
1535
1536    /// Logging configuration (level, format).
1537    #[serde(default)]
1538    pub log: LogConfig,
1539
1540    /// Telemetry configuration (OTLP tracing and service metadata).
1541    #[serde(default)]
1542    pub telemetry: TelemetryConfig,
1543
1544    /// Health check endpoint settings.
1545    #[serde(default)]
1546    pub health: HealthConfig,
1547
1548    /// Actuator endpoint settings.
1549    #[serde(default)]
1550    pub actuator: ActuatorConfig,
1551
1552    /// CORS (Cross-Origin Resource Sharing) settings.
1553    #[serde(default)]
1554    pub cors: CorsConfig,
1555
1556    /// Session management settings.
1557    #[serde(default)]
1558    pub session: crate::session::SessionConfig,
1559
1560    /// Cache backend settings.
1561    #[serde(default)]
1562    pub cache: CacheConfig,
1563
1564    /// Row-level multi-tenancy settings.
1565    #[serde(default)]
1566    pub tenancy: TenancyConfig,
1567
1568    /// Web Push settings (`[push]` section, issue #1392).
1569    ///
1570    /// Absent by default. The VAPID key it names is loaded and validated once
1571    /// at boot — a key that is present but unusable fails the boot rather than
1572    /// leaving the app running with push silently dead. See
1573    /// [`crate::push::PushConfig`] and `docs/guide/web-push.md`.
1574    #[serde(default)]
1575    pub push: crate::push::PushConfig,
1576
1577    /// HTTP idempotency-key middleware settings.
1578    #[serde(default)]
1579    pub idempotency: IdempotencyConfig,
1580
1581    /// Real-time channel backend settings.
1582    #[serde(default)]
1583    pub channels: ChannelConfig,
1584
1585    /// WebSocket limits. See [`RealtimeConfig`].
1586    #[serde(default)]
1587    pub realtime: RealtimeConfig,
1588
1589    /// Background job backend and runtime settings.
1590    #[serde(default)]
1591    pub jobs: JobConfig,
1592
1593    /// Transactional outbox relay settings (issue #3062).
1594    #[serde(default)]
1595    pub outbox: OutboxConfig,
1596
1597    /// Scheduled task coordination backend settings.
1598    #[serde(default)]
1599    pub scheduler: SchedulerConfig,
1600
1601    /// Process role: which slice of the runtime this replica runs (web tier,
1602    /// worker tier, or both). Defaults to [`ProcessRole::Combined`] so existing
1603    /// single-process deployments are unaffected. Also settable via the flat
1604    /// `AUTUMN_ROLE` env var.
1605    #[serde(default)]
1606    pub role: ProcessRole,
1607
1608    /// Authentication settings.
1609    #[serde(default)]
1610    pub auth: crate::auth::AuthConfig,
1611
1612    /// Security settings (headers, CSRF).
1613    #[serde(default)]
1614    pub security: crate::security::config::SecurityConfig,
1615
1616    /// Internationalization settings (default locale, supported locales,
1617    /// fallback chain). Populated from the `[i18n]` block in
1618    /// `autumn.toml`.
1619    #[cfg(feature = "i18n")]
1620    #[serde(default)]
1621    pub i18n: crate::i18n::I18nConfig,
1622
1623    /// Per-user time zone settings (`[time_zone]` block in `autumn.toml`).
1624    ///
1625    /// Controls the default IANA zone and the source resolution chain for the
1626    /// [`TimeZone`](crate::time_zone::TimeZone) extractor.
1627    ///
1628    /// # Example
1629    ///
1630    /// ```toml
1631    /// [time_zone]
1632    /// identifier = "America/New_York"
1633    /// ```
1634    #[serde(default)]
1635    pub time_zone: crate::time_zone::TimeZoneConfig,
1636    /// Pluggable file storage configuration. Honored only when the
1637    /// `storage` cargo feature is enabled.
1638    #[cfg(feature = "storage")]
1639    #[serde(default)]
1640    pub storage: crate::storage::StorageConfig,
1641
1642    /// Offsite database-backup destination (`[backup]` section, issue #1619).
1643    ///
1644    /// Composes the verified local-backup artifact (issue #1595) with an
1645    /// S3-compatible offsite destination. Always present (not feature-gated) so
1646    /// every autumn-web build recognizes `[backup.offsite]` — a strict-config app
1647    /// compiled without the `storage` feature still accepts its own `[backup]`
1648    /// keys. The offsite upload client lives in the CLI and needs no storage
1649    /// feature.
1650    #[serde(default)]
1651    pub backup: BackupConfig,
1652    /// Continuous `SQLite` replication (`[replication]` section, issue #1628).
1653    ///
1654    /// `None` (the default) means the section is absent and nothing is
1655    /// replicated. Boxed so an app that never replicates costs one pointer in
1656    /// the app-run future rather than the whole section inline.
1657    #[serde(default)]
1658    pub replication: Option<Box<ReplicationConfig>>,
1659    /// Transactional email settings.
1660    #[cfg(feature = "mail")]
1661    #[serde(default)]
1662    pub mail: crate::mail::MailConfig,
1663    /// `OpenAPI` spec runtime exposure settings.
1664    ///
1665    /// Controls whether the generated `OpenAPI` spec is served at runtime
1666    /// and at which path. Use `[openapi] enabled = false` in `autumn.toml`
1667    /// to suppress the spec endpoint in production.
1668    #[serde(default, rename = "openapi")]
1669    pub openapi_runtime: OpenApiRuntimeConfig,
1670
1671    /// Encrypted credentials store loaded from `config/credentials/<env>.toml.enc`.
1672    ///
1673    /// Empty when no credentials file exists (existing apps continue to boot unchanged).
1674    /// Prefer using `config.credentials().get::<String>("stripe_key")` for type-safe access.
1675    #[serde(skip)]
1676    pub credentials: crate::credentials::CredentialsStore,
1677
1678    /// Outbound HTTP settings (`[http]` section in `autumn.toml`).
1679    ///
1680    /// The nested `[http.client]` sub-table configures the outbound client.
1681    #[cfg(feature = "http-client")]
1682    #[serde(default, rename = "http")]
1683    pub http: HttpConfig,
1684
1685    /// Developer-experience settings (`[dev]` section in `autumn.toml`).
1686    ///
1687    /// Controls the request inspector and other dev-only features.
1688    /// These settings have no effect outside the `dev` profile.
1689    #[serde(default)]
1690    pub dev: DevConfig,
1691
1692    /// Widget story gallery settings (`[stories]` section in `autumn.toml`).
1693    ///
1694    /// Off by default; opt-in per profile (e.g. `[profile.dev.stories]
1695    /// enabled = true` for a dev-only gallery, or a prod profile for a
1696    /// public showcase). See `docs/guide/stories.md`.
1697    #[cfg(feature = "maud")]
1698    #[serde(default)]
1699    pub stories: crate::stories::StoriesConfig,
1700
1701    /// Error-reporting settings (`[reporting]` section in `autumn.toml`).
1702    ///
1703    /// Controls delivery of panic + 5xx [`ErrorEvent`](crate::reporting::ErrorEvent)s
1704    /// to registered reporters. Honored only when the `reporting` cargo
1705    /// feature is enabled.
1706    #[cfg(feature = "reporting")]
1707    #[serde(default)]
1708    pub reporting: ReportingConfig,
1709
1710    /// Response compression settings (`[compression]` section in `autumn.toml`).
1711    ///
1712    /// Compression is **off by default**. Enable with:
1713    /// ```toml
1714    /// [compression]
1715    /// enabled = true
1716    /// ```
1717    /// or via `AUTUMN_COMPRESSION__ENABLED=true`.
1718    #[serde(default)]
1719    pub compression: CompressionConfig,
1720
1721    /// Bot protection / CAPTCHA settings (`[bot_protection]` section in `autumn.toml`).
1722    ///
1723    /// Requires a CAPTCHA token on mutating requests (POST/PUT/PATCH/DELETE) to
1724    /// protect public-facing forms against automated abuse.
1725    ///
1726    /// # Example
1727    ///
1728    /// ```toml
1729    /// [bot_protection]
1730    /// enabled    = true
1731    /// provider   = "turnstile"      # "turnstile" (default) or "hcaptcha"
1732    /// site_key   = "0x4AAAA..."     # public key — safe to commit
1733    /// secret_key = "..."            # private key — use env var!
1734    /// dev_bypass = false
1735    /// ```
1736    #[serde(default)]
1737    pub bot_protection: crate::security::captcha::BotProtectionConfig,
1738
1739    /// Resilience settings (circuit breakers, fallbacks).
1740    #[serde(default)]
1741    pub resilience: ResilienceConfig,
1742
1743    /// SEO settings (`[seo]` section in `autumn.toml`).
1744    ///
1745    /// Controls sitemap generation, robots.txt behavior, and canonical URL
1746    /// computation. See [`crate::seo`] for the full surface.
1747    ///
1748    /// # Example `autumn.toml`
1749    ///
1750    /// ```toml
1751    /// [seo]
1752    /// base_url = "https://example.com"
1753    ///
1754    /// [seo.robots]
1755    /// additional_rules = ["Disallow: /admin"]
1756    /// ```
1757    #[serde(default)]
1758    pub seo: SeoConfig,
1759
1760    /// Agent readiness (AEO) settings (`[aeo]` in `autumn.toml`).
1761    ///
1762    /// On by default. See [`crate::aeo`] and `docs/guide/aeo.md`.
1763    #[serde(default)]
1764    pub aeo: crate::aeo::AeoConfig,
1765
1766    /// Observability settings (`[observability]` section in `autumn.toml`).
1767    ///
1768    /// Controls opt-in framework-emitted telemetry that supplements the
1769    /// access log — currently the `Server-Timing` response header. See
1770    /// [`ObservabilityConfig`] and `docs/guide/observability/server-timing.md`.
1771    #[serde(default)]
1772    pub observability: ObservabilityConfig,
1773
1774    /// Cost accounting and deferral settings (`[cost]` section in
1775    /// `autumn.toml`, issue #1720). See [`CostConfig`] and `docs/guide/cost.md`.
1776    #[serde(default)]
1777    pub cost: CostConfig,
1778
1779    /// Operator alerts settings (`[alerts]` section in `autumn.toml`).
1780    ///
1781    /// Configure an operator email and/or a webhook URL to receive alerts for
1782    /// built-in failure conditions (dead-lettered jobs, Down health indicators,
1783    /// 5xx-rate spikes, scheduled-task failures) with zero application code.
1784    /// See [`crate::alerts::AlertConfig`] and `docs/guide/operator-alerts.md`.
1785    ///
1786    /// Boxed so the large `[alerts]` struct is stored behind a pointer rather
1787    /// than inline in `AutumnConfig`: `AutumnConfig` is held by value on the
1788    /// `app().run()` stack frame across await points, and inlining
1789    /// `AlertConfig` (many `Option<String>` destinations + tuning knobs) grew
1790    /// that future past the `clippy::large_futures` threshold. `Box<T>` keeps
1791    /// `Default`/`Deserialize` (both hold when `T` does), and field
1792    /// reads/writes still work through `Deref`/`DerefMut`.
1793    #[serde(default)]
1794    pub alerts: Box<crate::alerts::AlertConfig>,
1795
1796    /// Unified data-retention policy for framework-owned data
1797    /// (`[retention]` section in `autumn.toml`, issue #1605).
1798    ///
1799    /// One retention window per framework-owned dataset (job history, job
1800    /// tracking, idempotency records, experiment assignments, webhook replay
1801    /// markers, sessions, audit archives). Every window is unset by default,
1802    /// which preserves today's behavior exactly. See [`RetentionConfig`] and
1803    /// `docs/guide/data-retention.md`.
1804    #[serde(default)]
1805    pub retention: RetentionConfig,
1806}
1807
1808/// Opt-in TLS termination at the deploy-managed reverse proxy (`[deploy.tls]`
1809/// table, issue #1969).
1810///
1811/// Absent/disabled by default, so a deploy without this table is byte-for-byte
1812/// the historical HTTP-only behavior. When `enabled = true`, `autumn deploy`
1813/// wires the public `host` into kamal-proxy (`--host`/`--tls`) so the proxy
1814/// terminates TLS on 443 with an automatic Let's Encrypt certificate.
1815///
1816/// TLS terminates at the PROXY only — the app itself keeps serving plain HTTP on
1817/// its private loopback port, and its readiness/health probes are unaffected. Do
1818/// NOT also enable in-process `[server.tls]`/ACME on a deploy-managed app.
1819///
1820/// # `autumn.toml` example
1821///
1822/// ```toml
1823/// [deploy.tls]
1824/// enabled = true
1825/// host = "app.example.com"
1826/// ```
1827#[derive(Debug, Clone, Default, Deserialize)]
1828pub struct DeployTlsConfig {
1829    /// Whether the deploy-managed proxy terminates TLS on 443. Default: `false`
1830    /// (HTTP-only, unchanged behavior).
1831    #[serde(default)]
1832    pub enabled: bool,
1833
1834    /// Public hostname the certificate is issued for (the DNS name pointing at
1835    /// the server). Required when `enabled = true`; enforced at resolve time by
1836    /// the CLI's `ResolvedDeployConfig::resolve`.
1837    #[serde(default)]
1838    pub host: Option<String>,
1839}
1840
1841/// The post-cutover bake (`[deploy.bake]`, issue #3069).
1842///
1843/// After each host cuts over, `autumn deploy up` samples the new release's
1844/// `/actuator/metrics` for `duration_secs`. It rolls the host back when the
1845/// 5xx ratio or the latency is above the limit. The bake is off by default.
1846///
1847/// ```toml
1848/// [deploy.bake]
1849/// duration_secs = 300      # 0 turns the bake off (default: 0)
1850/// interval_secs = 10       # time between samples (default: 10)
1851/// min_requests = 20        # fewest responses for a verdict (default: 20)
1852/// max_error_rate = 0.01    # default: from [[slo]], else 0.05
1853/// max_p99_ms = 500         # default: from [[slo]], else no latency check
1854/// ```
1855///
1856/// When `max_error_rate` is not set, the limit comes from the `[[slo]]`
1857/// availability objectives with no `route`: the error ratio that burns the
1858/// budget at 14.4×. When `max_p99_ms` is not set, it comes from the `[[slo]]`
1859/// latency objectives with no `route`.
1860#[derive(Debug, Clone, Deserialize)]
1861pub struct DeployBakeConfig {
1862    /// Bake time in seconds after each host cuts over. `0` turns the bake
1863    /// off. Default: `0`.
1864    #[serde(default)]
1865    pub duration_secs: u64,
1866    /// Seconds between metric samples. Default: `10`.
1867    #[serde(default = "default_deploy_bake_interval_secs")]
1868    pub interval_secs: u64,
1869    /// The fewest new responses before the bake gives a verdict. Thin
1870    /// traffic never causes a rollback. Default: `20`.
1871    #[serde(default = "default_deploy_bake_min_requests")]
1872    pub min_requests: u64,
1873    /// The highest 5xx ratio, from `0.0` to `1.0`.
1874    #[serde(default)]
1875    pub max_error_rate: Option<f64>,
1876    /// The highest p99 latency, in milliseconds.
1877    #[serde(default)]
1878    pub max_p99_ms: Option<u64>,
1879}
1880
1881const fn default_deploy_bake_interval_secs() -> u64 {
1882    10
1883}
1884
1885const fn default_deploy_bake_min_requests() -> u64 {
1886    20
1887}
1888
1889impl Default for DeployBakeConfig {
1890    fn default() -> Self {
1891        Self {
1892            duration_secs: 0,
1893            interval_secs: default_deploy_bake_interval_secs(),
1894            min_requests: default_deploy_bake_min_requests(),
1895            max_error_rate: None,
1896            max_p99_ms: None,
1897        }
1898    }
1899}
1900
1901/// Push-button VPS deploy settings (`[deploy]` section, issue #1607).
1902///
1903/// Describes the SSH-reachable target server and the remote install layout for
1904/// `autumn deploy`'s zero-downtime rollout. Everything except `host` has a
1905/// sensible default, and `app_name`/`app_dir`/`service_name` are resolved from
1906/// the project's package name at deploy time (not during deserialization) so an
1907/// unset value stays `None` here.
1908///
1909/// # `autumn.toml` example
1910///
1911/// ```toml
1912/// [deploy]
1913/// host = "203.0.113.10"      # required at deploy time; SSH-reachable address
1914/// # hosts = ["10.0.0.1", "10.0.0.2"]  # fleet alternative to `host` (#1621);
1915/// #                                   # mutually exclusive with it, and the
1916/// #                                   # order is the rollout order
1917/// user = "deploy"            # SSH user (default: "root")
1918/// ssh_port = 22              # SSH port (default: 22)
1919/// app_name = "myapp"         # default: the crate's package name
1920/// app_dir = "/srv/myapp"     # default: /srv/autumn/{app_name}
1921/// service_name = "myapp"     # systemd unit name; default: {app_name}
1922/// readiness_timeout_secs = 60 # readiness window before rollback (default: 60)
1923/// keep_releases = 3          # releases retained on the host (default: 3)
1924/// profile = "prod"           # profile the deployed app runs under (default: "prod")
1925/// install_proxy = true       # install the reverse proxy on a bare host (default: true)
1926/// ```
1927#[derive(Debug, Clone, Deserialize)]
1928pub struct DeployConfig {
1929    /// SSH-reachable address (hostname or IP) of the target server.
1930    ///
1931    /// Required when a deploy actually runs (`autumn deploy`), but `None` is
1932    /// valid at rest so a bare `[deploy]` table parses. Enforced by
1933    /// [`validate`](Self::validate).
1934    #[serde(default)]
1935    pub host: Option<String>,
1936
1937    /// SSH-reachable addresses of the target fleet, in rollout order (#1621).
1938    ///
1939    /// Mutually exclusive with [`host`](Self::host): a single-entry `hosts` list
1940    /// is byte-for-byte the historical single-server deploy, and the order of the
1941    /// list **is** the rollout order (a documented operator contract, not an
1942    /// implementation detail — the fleet driver never sorts or regroups it).
1943    /// Empty by default, so every pre-#1621 `[deploy]` table is unchanged.
1944    ///
1945    /// `[deploy.tls] host` stays fleet-singular: it is the PUBLIC DNS name the
1946    /// certificate is issued for, semantically distinct from these SSH targets.
1947    ///
1948    /// Blank entries and duplicates (compared after trimming) are rejected, as is
1949    /// setting this alongside `host`. The enforcing seam is the CLI's
1950    /// `ResolvedFleet::resolve` — the only validation a deploy actually calls;
1951    /// [`validate`](Self::validate) mirrors the same rules so the two never
1952    /// disagree about what a valid `[deploy]` table looks like.
1953    #[serde(default)]
1954    pub hosts: Vec<String>,
1955
1956    /// SSH user to connect as. Default: `"root"`.
1957    #[serde(default = "default_deploy_user")]
1958    pub user: String,
1959
1960    /// SSH port on the target host. Default: `22`.
1961    #[serde(default = "default_deploy_ssh_port")]
1962    pub ssh_port: u16,
1963
1964    /// Application name used to derive remote paths and the service unit.
1965    /// Resolved to the project's package name when unset (at deploy time, not
1966    /// during deserialization).
1967    #[serde(default)]
1968    pub app_name: Option<String>,
1969
1970    /// Remote install directory. Resolved to `/srv/autumn/{app_name}` when
1971    /// unset (at deploy time).
1972    #[serde(default)]
1973    pub app_dir: Option<String>,
1974
1975    /// systemd unit name. Resolved to `{app_name}` when unset (at deploy time).
1976    #[serde(default)]
1977    pub service_name: Option<String>,
1978
1979    /// Bounded readiness window, in seconds, the new release has to report
1980    /// `/ready` before the deploy rolls back. Default: `60`.
1981    #[serde(default = "default_deploy_readiness_timeout_secs")]
1982    pub readiness_timeout_secs: u64,
1983
1984    /// Number of prior releases retained on the host for rollback. Default: `3`.
1985    #[serde(default = "default_deploy_keep_releases")]
1986    pub keep_releases: u32,
1987
1988    /// The profile the deployed app runs under (written into the host env file
1989    /// as `AUTUMN_ENV`). Defaults to the production profile (`"prod"`) so a
1990    /// deploy never silently runs the `dev` profile; set to e.g. `"staging"`
1991    /// for non-prod targets.
1992    #[serde(default = "default_deploy_profile")]
1993    pub profile: String,
1994
1995    /// Opt-in TLS termination at the deploy-managed reverse proxy
1996    /// (`[deploy.tls]`). Disabled by default — an absent table is byte-for-byte
1997    /// the historical HTTP-only behavior. See [`DeployTlsConfig`].
1998    #[serde(default)]
1999    pub tls: DeployTlsConfig,
2000
2001    /// Whether `autumn deploy` may PREPARE the target host by installing the
2002    /// reverse-proxy binary when the host has none. Default: `true` (issue #1607,
2003    /// AC-1 — the documented target-host precondition is at most a stock Ubuntu
2004    /// LTS with SSH access, so the command performs the remaining host preparation
2005    /// itself).
2006    ///
2007    /// Probe-gated and idempotent: a host that already has a working proxy binary
2008    /// is never touched, and a binary that responds but whose CLI surface has
2009    /// drifted is never replaced (that stays a hard, actionable refusal).
2010    ///
2011    /// Set to `false` when you provision the proxy yourself — a pinned internal
2012    /// build, your own package, or a host you do not want a container runtime
2013    /// installed on. A missing binary is then an actionable deploy failure instead
2014    /// of something the deploy fixes.
2015    #[serde(default = "default_deploy_install_proxy")]
2016    pub install_proxy: bool,
2017
2018    /// The post-cutover bake (`[deploy.bake]`, issue #3069). Off by default.
2019    /// See [`DeployBakeConfig`].
2020    #[serde(default)]
2021    pub bake: DeployBakeConfig,
2022}
2023
2024/// Default for [`DeployConfig::install_proxy`]: prepare the host (issue #1607).
2025const fn default_deploy_install_proxy() -> bool {
2026    true
2027}
2028
2029impl Default for DeployConfig {
2030    fn default() -> Self {
2031        Self {
2032            host: None,
2033            // #1621: empty, so an existing single-host `[deploy]` table (and the
2034            // type default) is byte-for-byte unchanged.
2035            hosts: Vec::new(),
2036            user: default_deploy_user(),
2037            ssh_port: default_deploy_ssh_port(),
2038            app_name: None,
2039            app_dir: None,
2040            service_name: None,
2041            readiness_timeout_secs: default_deploy_readiness_timeout_secs(),
2042            keep_releases: default_deploy_keep_releases(),
2043            profile: default_deploy_profile(),
2044            tls: DeployTlsConfig::default(),
2045            install_proxy: default_deploy_install_proxy(),
2046            bake: DeployBakeConfig::default(),
2047        }
2048    }
2049}
2050
2051impl DeployConfig {
2052    /// Validate the `[deploy]` section for a context that actually runs a deploy.
2053    ///
2054    /// A bare `[deploy]` table is valid at rest, but a deploy needs a target:
2055    /// this rejects a missing or blank `host` with an actionable message so the
2056    /// operator knows exactly which key to set.
2057    ///
2058    /// Since #1621 it also mirrors the fleet rules the CLI's
2059    /// `ResolvedFleet::resolve` enforces — `host`/`hosts` mutual exclusion, no
2060    /// blank entry, no duplicate entry — in the same order, so the two surfaces
2061    /// never disagree about what a valid `[deploy]` table looks like.
2062    ///
2063    /// **This method has no production call site** (it is not reached from
2064    /// [`AutumnConfig::validate`]); the CLI's resolve step is the enforcing seam.
2065    /// Keep the rules here in sync anyway: a future wiring must not change
2066    /// behavior.
2067    ///
2068    /// # Errors
2069    ///
2070    /// Returns a message when `host` and `hosts` are both set, when a `hosts`
2071    /// entry is blank or duplicated, or when neither key provides a target.
2072    pub fn validate(&self) -> Result<(), String> {
2073        let host = self
2074            .host
2075            .as_deref()
2076            .map(str::trim)
2077            .filter(|host| !host.is_empty());
2078
2079        // 1. Mutual exclusion: with both spellings set the rollout order is
2080        //    ambiguous, so name BOTH keys and let the operator pick one.
2081        if host.is_some() && !self.hosts.is_empty() {
2082            return Err(
2083                "[deploy] host and [deploy] hosts are mutually exclusive: keep the \
2084                 single-server `[deploy] host = \"<address>\"` or the fleet list \
2085                 `[deploy] hosts = [\"<address>\", …]` in autumn.toml, not both (#1621)"
2086                    .to_owned(),
2087            );
2088        }
2089
2090        // 2. A blank entry would resolve to a hostless SSH target mid-rollout.
2091        for (index, entry) in self.hosts.iter().enumerate() {
2092            if entry.trim().is_empty() {
2093                return Err(format!(
2094                    "[deploy] hosts entry {index} is blank: every fleet entry must be an \
2095                     SSH-reachable hostname or IP (#1621)"
2096                ));
2097            }
2098        }
2099
2100        // 3. A duplicate would deploy the same server twice — the second pass sees
2101        //    its own new release as live and corrupts the previous-release chain a
2102        //    rollback depends on. Compared after trimming; DNS aliases are a
2103        //    documented limitation.
2104        let mut seen: Vec<&str> = Vec::with_capacity(self.hosts.len());
2105        for entry in &self.hosts {
2106            let trimmed = entry.trim();
2107            if seen.contains(&trimmed) {
2108                return Err(format!(
2109                    "[deploy] hosts lists `{trimmed}` more than once: each fleet host must \
2110                     appear exactly once (#1621)"
2111                ));
2112            }
2113            seen.push(trimmed);
2114        }
2115
2116        // 4. Neither spelling provides a target.
2117        if host.is_none() && self.hosts.is_empty() {
2118            return Err(
2119                "[deploy] requires a target host: set `[deploy] host = \"<address>\"` in \
2120                      autumn.toml to the SSH-reachable hostname or IP of your server, or \
2121                      `[deploy] hosts = [\"<address>\", …]` for a fleet (#1621)"
2122                    .to_owned(),
2123            );
2124        }
2125
2126        Ok(())
2127    }
2128}
2129
2130/// Cost accounting settings (`[cost]` section in `autumn.toml`, issue #1720).
2131///
2132/// `enabled` turns on per-request metering. `defer_threshold` turns on
2133/// deferral: deferrable jobs and tasks wait while the
2134/// [`CostSignal`](crate::cost::CostSignal) is above it. The two settings are
2135/// independent.
2136///
2137/// ```toml
2138/// [cost]
2139/// enabled = true
2140/// defer_threshold = 400.0   # g/kWh, or your price unit
2141/// defer_recheck_secs = 30
2142/// max_tenants = 1000
2143/// signal_refresh_secs = 5
2144/// ```
2145#[derive(Debug, Clone, Deserialize)]
2146#[non_exhaustive]
2147pub struct CostConfig {
2148    /// Meter CPU time, allocated bytes and DB queries for each request.
2149    /// Default: `false`.
2150    #[serde(default)]
2151    pub enabled: bool,
2152    /// Deferrable work waits while the cost signal is above this value.
2153    /// Default: unset, so work never waits.
2154    #[serde(default)]
2155    pub defer_threshold: Option<f64>,
2156    /// Seconds between two checks of the signal while work waits.
2157    /// Default: `30`.
2158    #[serde(default = "default_cost_defer_recheck_secs")]
2159    pub defer_recheck_secs: u64,
2160    /// Most tenant keys that the accountant keeps. More tenants go into the
2161    /// `_other` key. Default: `1000`.
2162    #[serde(default = "default_cost_max_tenants")]
2163    pub max_tenants: usize,
2164    /// Seconds between two reads of the `autumn_cost_signal` runtime-config
2165    /// key. Default: `5`.
2166    #[serde(default = "default_cost_signal_refresh_secs")]
2167    pub signal_refresh_secs: u64,
2168}
2169
2170impl CostConfig {
2171    /// Check the values.
2172    ///
2173    /// # Errors
2174    ///
2175    /// Returns a message that names the bad key.
2176    pub fn validate(&self) -> Result<(), String> {
2177        if self.defer_recheck_secs == 0 {
2178            return Err("cost.defer_recheck_secs must be at least 1".to_owned());
2179        }
2180        if self.signal_refresh_secs == 0 {
2181            return Err("cost.signal_refresh_secs must be at least 1".to_owned());
2182        }
2183        if let Some(threshold) = self.defer_threshold
2184            && !(threshold.is_finite() && threshold >= 0.0)
2185        {
2186            return Err(format!(
2187                "cost.defer_threshold must be a finite number >= 0, got {threshold}"
2188            ));
2189        }
2190        Ok(())
2191    }
2192}
2193
2194impl Default for CostConfig {
2195    fn default() -> Self {
2196        Self {
2197            enabled: false,
2198            defer_threshold: None,
2199            defer_recheck_secs: default_cost_defer_recheck_secs(),
2200            max_tenants: default_cost_max_tenants(),
2201            signal_refresh_secs: default_cost_signal_refresh_secs(),
2202        }
2203    }
2204}
2205
2206const fn default_cost_defer_recheck_secs() -> u64 {
2207    30
2208}
2209
2210const fn default_cost_max_tenants() -> usize {
2211    1000
2212}
2213
2214const fn default_cost_signal_refresh_secs() -> u64 {
2215    5
2216}
2217
2218/// Observability configuration (`[observability]` section in `autumn.toml`).
2219///
2220/// Controls opt-in telemetry that supplements the default access log.
2221///
2222/// # Server-Timing header
2223///
2224/// When `server_timing = true`, Autumn emits a W3C-conformant
2225/// [`Server-Timing`](https://www.w3.org/TR/server-timing/) header on every
2226/// non-streaming response with at minimum a `total` metric (whole-request
2227/// wall time, matching the access-log `duration_ms`) and a `db` metric
2228/// summarising cumulative query time plus a query count (`db;dur=…;desc="N queries"`)
2229/// when at least one query ran during the request.
2230///
2231/// The default is **off in production** and **on in the `dev` profile**;
2232/// leave the field unset for that behavior, or pin it to `true` / `false`
2233/// explicitly. Requires opt-in in prod because timings can leak
2234/// infrastructure detail to anonymous clients.
2235///
2236/// # Example
2237///
2238/// ```toml
2239/// # Force on in a staging profile where dev-team browsers inspect timings.
2240/// [observability]
2241/// server_timing = true
2242/// ```
2243///
2244/// ```toml
2245/// # Force off during a dev-profile perf comparison against production.
2246/// [observability]
2247/// server_timing = false
2248/// ```
2249#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
2250pub struct ObservabilityConfig {
2251    /// Emit the `Server-Timing` response header on served requests.
2252    ///
2253    /// `None` (unset) means the effective value follows the profile default:
2254    /// on in `dev`/`development`, off everywhere else. `Some(true)` or
2255    /// `Some(false)` pin the choice explicitly.
2256    #[serde(default)]
2257    pub server_timing: Option<bool>,
2258}
2259
2260/// Resolve the effective value of `[observability] server_timing` for a
2261/// given [`AutumnConfig`].
2262///
2263/// The rules are:
2264/// - Explicit `Some(true)` / `Some(false)` in config or env → returned as-is.
2265/// - `None` (unset) → `true` iff the active profile is `"dev"` or
2266///   `"development"`, otherwise `false`. This keeps production off by
2267///   default so timings never leak to anonymous clients without opt-in.
2268pub(crate) fn server_timing_enabled(cfg: &AutumnConfig) -> bool {
2269    if let Some(explicit) = cfg.observability.server_timing {
2270        return explicit;
2271    }
2272    matches!(cfg.profile.as_deref(), Some("dev" | "development"))
2273}
2274
2275/// SEO configuration (`[seo]` section in `autumn.toml`).
2276///
2277/// # Example
2278///
2279/// ```toml
2280/// [seo]
2281/// base_url = "https://example.com"
2282///
2283/// [seo.robots]
2284/// additional_rules = ["Disallow: /admin"]
2285/// ```
2286#[derive(Debug, Clone, Default, Deserialize)]
2287pub struct SeoConfig {
2288    /// Base URL used for canonical URL computation and sitemap auto-injection.
2289    ///
2290    /// E.g. `"https://example.com"`. When set, the `Sitemap:` directive is
2291    /// automatically injected into `robots.txt`.
2292    pub base_url: Option<String>,
2293
2294    /// Robots.txt overrides.
2295    #[serde(default)]
2296    pub robots: RobotsConfig,
2297}
2298
2299/// Per-profile `robots.txt` overrides (`[seo.robots]` in `autumn.toml`).
2300///
2301/// The framework default behavior (dev/test → disallow all; prod → allow all)
2302/// can be overridden here.
2303#[derive(Debug, Clone, Default, Deserialize)]
2304pub struct RobotsConfig {
2305    /// Override the profile-driven allow/disallow default.
2306    ///
2307    /// `None` means: use the profile default (dev → disallow, prod → allow).
2308    /// `Some(true)` forces `Allow: /`; `Some(false)` forces `Disallow: /`.
2309    pub allow_all: Option<bool>,
2310
2311    /// Additional directives appended after the main `User-agent` block.
2312    ///
2313    /// Example: `["Disallow: /admin", "Crawl-delay: 5"]`
2314    #[serde(default)]
2315    pub additional_rules: Vec<String>,
2316
2317    /// Explicit `Sitemap:` URL.
2318    ///
2319    /// When `None`, the URL is auto-computed from `[seo] base_url` if set.
2320    pub sitemap_url: Option<String>,
2321}
2322
2323/// Error-reporting settings (`[reporting]` section in `autumn.toml`).
2324///
2325/// # Example `autumn.toml`
2326///
2327/// ```toml
2328/// [reporting]
2329/// enabled = true      # deliver events to reporters (default: true)
2330/// sample_rate = 0.25  # report ~25% of events (default: 1.0 = all)
2331/// ```
2332///
2333/// Note: `enabled = false` only suppresses *delivery* to reporters. Handler
2334/// panics are still caught and converted to a clean 500 response regardless of
2335/// this setting.
2336#[cfg(feature = "reporting")]
2337#[derive(Debug, Clone, Deserialize)]
2338pub struct ReportingConfig {
2339    /// Whether error events are delivered to registered reporters.
2340    ///
2341    /// Defaults to `true`. When `false`, panics are still caught and turned
2342    /// into clean 500 responses, but no [`ErrorEvent`](crate::reporting::ErrorEvent)
2343    /// is dispatched.
2344    #[serde(default = "default_reporting_enabled")]
2345    pub enabled: bool,
2346    /// Fraction of events to deliver, in `[0.0, 1.0]`.
2347    ///
2348    /// `1.0` (the default) reports every event; `0.0` reports none. Values
2349    /// outside the range are clamped at the extremes.
2350    #[serde(default = "default_reporting_sample_rate")]
2351    pub sample_rate: f64,
2352}
2353
2354#[cfg(feature = "reporting")]
2355impl Default for ReportingConfig {
2356    fn default() -> Self {
2357        Self {
2358            enabled: default_reporting_enabled(),
2359            sample_rate: default_reporting_sample_rate(),
2360        }
2361    }
2362}
2363
2364#[cfg(feature = "reporting")]
2365const fn default_reporting_enabled() -> bool {
2366    true
2367}
2368
2369#[cfg(feature = "reporting")]
2370const fn default_reporting_sample_rate() -> f64 {
2371    1.0
2372}
2373
2374/// Deterministic replay capsule settings (`[failure_capture]` section in
2375/// `autumn.toml`, issue #1598).
2376///
2377/// # Example `autumn.toml`
2378///
2379/// ```toml
2380/// [failure_capture]
2381/// enabled = true                  # record capsules for failing requests (default: false)
2382/// dir = "tmp/autumn-capsules"     # where capsules are written (project-relative)
2383/// max_body_bytes = 65536          # largest request body copied into a capsule
2384/// max_capsule_bytes = 1048576     # effect budget before a capsule is marked truncated
2385/// max_capsules = 50               # retained capsules; oldest are pruned
2386/// ```
2387///
2388/// **A capsule holds real production request data and real database rows.**
2389/// Sensitive headers, query parameters and structured body fields are masked
2390/// through `[log] filter_parameters`, but unstructured bodies, URL paths and
2391/// result rows are not. Read `docs/guide/failure-capsules.md` before enabling
2392/// this outside development.
2393#[cfg(feature = "reporting")]
2394#[derive(Debug, Clone, Deserialize)]
2395pub struct FailureCaptureConfig {
2396    /// Whether failing requests are recorded as capsules.
2397    ///
2398    /// Defaults to `false` — capture costs a teed request body and a
2399    /// teed database stream on every request, and the artifacts contain
2400    /// production data.
2401    #[serde(default = "default_failure_capture_enabled")]
2402    pub enabled: bool,
2403
2404    /// Directory capsules are written to, project-relative by default
2405    /// (mirroring `tmp/autumn-maintenance.json`).
2406    #[serde(default = "default_failure_capture_dir")]
2407    pub dir: String,
2408
2409    /// Largest request body copied into a capsule, in bytes.
2410    ///
2411    /// A body larger than this is never consumed at all — the handler still
2412    /// receives it intact and the capsule records it as skipped.
2413    #[serde(default = "default_failure_capture_max_body_bytes")]
2414    pub max_body_bytes: usize,
2415
2416    /// Budget for recorded effects before a capsule is marked truncated.
2417    ///
2418    /// Recording stops at the ceiling; the capsule is still written (so the
2419    /// failure is not lost) but replay refuses it.
2420    #[serde(default = "default_failure_capture_max_capsule_bytes")]
2421    pub max_capsule_bytes: usize,
2422
2423    /// How many capsules to retain in `dir`; the oldest beyond this are
2424    /// pruned after each write so an error storm cannot fill a disk.
2425    #[serde(default = "default_failure_capture_max_capsules")]
2426    pub max_capsules: usize,
2427}
2428
2429#[cfg(feature = "reporting")]
2430impl Default for FailureCaptureConfig {
2431    fn default() -> Self {
2432        Self {
2433            enabled: default_failure_capture_enabled(),
2434            dir: default_failure_capture_dir(),
2435            max_body_bytes: default_failure_capture_max_body_bytes(),
2436            max_capsule_bytes: default_failure_capture_max_capsule_bytes(),
2437            max_capsules: default_failure_capture_max_capsules(),
2438        }
2439    }
2440}
2441
2442#[cfg(feature = "reporting")]
2443const fn default_failure_capture_enabled() -> bool {
2444    false
2445}
2446
2447#[cfg(feature = "reporting")]
2448fn default_failure_capture_dir() -> String {
2449    "tmp/autumn-capsules".to_owned()
2450}
2451
2452#[cfg(feature = "reporting")]
2453const fn default_failure_capture_max_body_bytes() -> usize {
2454    65_536
2455}
2456
2457#[cfg(feature = "reporting")]
2458const fn default_failure_capture_max_capsule_bytes() -> usize {
2459    1_048_576
2460}
2461
2462#[cfg(feature = "reporting")]
2463const fn default_failure_capture_max_capsules() -> usize {
2464    50
2465}
2466
2467/// Developer-experience settings (`[dev]` section in `autumn.toml`).
2468///
2469/// All fields are ignored outside the `dev` profile.
2470///
2471/// # Example `autumn.toml`
2472///
2473/// ```toml
2474/// [dev]
2475/// inspector_path = "/_autumn/inspect"
2476/// inspector_capacity = 200
2477/// inspector_n_plus_one_threshold = 3
2478/// ```
2479#[derive(Debug, Clone, Deserialize)]
2480pub struct DevConfig {
2481    /// Mount path for the request inspector UI.
2482    ///
2483    /// Default: `"/_autumn/inspect"`. Only active in the `dev` profile;
2484    /// ignored everywhere else.
2485    #[serde(default = "default_inspector_path")]
2486    pub inspector_path: String,
2487
2488    /// Maximum number of requests retained in the in-memory ring buffer.
2489    ///
2490    /// Default: `100`. Set to `0` to disable recording without removing
2491    /// the middleware.
2492    #[serde(default = "default_inspector_capacity")]
2493    pub inspector_capacity: usize,
2494
2495    /// Minimum number of structurally identical SQL statements in a single
2496    /// request before an N+1 warning is emitted.
2497    ///
2498    /// Default: `5`. Set to `0` to disable N+1 detection.
2499    #[serde(default = "default_inspector_n_plus_one_threshold")]
2500    pub inspector_n_plus_one_threshold: usize,
2501}
2502
2503impl Default for DevConfig {
2504    fn default() -> Self {
2505        Self {
2506            inspector_path: default_inspector_path(),
2507            inspector_capacity: default_inspector_capacity(),
2508            inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
2509        }
2510    }
2511}
2512
2513fn default_inspector_path() -> String {
2514    "/_autumn/inspect".to_owned()
2515}
2516
2517const fn default_inspector_capacity() -> usize {
2518    100
2519}
2520
2521const fn default_inspector_n_plus_one_threshold() -> usize {
2522    crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD
2523}
2524
2525/// Top-level `[http]` configuration section.
2526#[cfg(feature = "http-client")]
2527#[derive(Debug, Clone, Default, Deserialize)]
2528pub struct HttpConfig {
2529    /// Outbound HTTP client settings (`[http.client]`).
2530    #[serde(default)]
2531    pub client: HttpClientConfig,
2532}
2533
2534/// Configuration for the outbound HTTP client (`[http.client]` in `autumn.toml`).
2535///
2536/// # Example `autumn.toml`
2537///
2538/// ```toml
2539/// [http.client]
2540/// timeout_secs = 30
2541/// max_retries  = 3
2542///
2543/// [http.client.base_urls]
2544/// stripe   = "https://api.stripe.com"
2545/// sendgrid = "https://api.sendgrid.com"
2546/// ```
2547#[cfg(feature = "http-client")]
2548#[derive(Debug, Clone, Deserialize)]
2549pub struct HttpClientConfig {
2550    /// Per-request timeout in seconds. Default: 30.
2551    #[serde(default = "default_http_timeout_secs")]
2552    pub timeout_secs: u64,
2553
2554    /// Maximum retry attempts for transient failures on idempotent methods.
2555    /// Default: 3 (four total attempts).
2556    #[serde(default = "default_http_max_retries")]
2557    pub max_retries: u32,
2558
2559    /// Cap on a `Retry-After` hint, in seconds. Default: 10. The wait is
2560    /// also at most the backoff plus 5 s (issue #3054), so a value above 5
2561    /// has no effect.
2562    #[serde(default = "default_http_max_retry_after_secs")]
2563    pub max_retry_after_secs: u64,
2564
2565    /// Cap on the jittered retry backoff in milliseconds. Default: 20 000.
2566    /// See [`crate::backoff`].
2567    #[serde(default = "default_http_max_backoff_ms")]
2568    pub max_backoff_ms: u64,
2569
2570    /// Named base URL aliases, e.g. `stripe = "https://api.stripe.com"`.
2571    ///
2572    /// A [`Client`](crate::http_client::Client) configured with `.named("stripe")` will
2573    /// prepend this URL to relative request paths and match against mocks
2574    /// registered for that alias via
2575    /// [`TestApp::http_mock`](crate::test::TestApp::http_mock).
2576    #[serde(default)]
2577    pub base_urls: std::collections::HashMap<String, String>,
2578
2579    /// Send the time left of the request deadline downstream in the
2580    /// `x-autumn-deadline-ms` header. The client sends it only when a deadline
2581    /// is set. Default: `true`.
2582    #[serde(default = "default_http_send_deadline_header")]
2583    pub send_deadline_header: bool,
2584
2585    /// Retry budget (`[http.client.retry_budget]`). See
2586    /// [`RetryBudgetConfig`].
2587    #[serde(default)]
2588    pub retry_budget: RetryBudgetConfig,
2589
2590    /// Client-side adaptive throttling per host (issue #3068). Off by
2591    /// default. See [`AdaptiveThrottleConfig`].
2592    #[serde(default)]
2593    pub adaptive_throttle: AdaptiveThrottleConfig,
2594}
2595
2596/// Retry budget settings (`[http.client.retry_budget]`, issue #3058).
2597///
2598/// The outbound client keeps a token bucket for each upstream host. A retry
2599/// takes tokens. Each first attempt adds `retry_ratio x transient_cost`
2600/// tokens. A first attempt never waits for tokens. See the
2601/// [timeouts and budgets guide](https://github.com/autumn-foundation/autumn/blob/trunk/docs/guide/timeouts-and-budgets.md).
2602///
2603/// ```toml
2604/// [http.client.retry_budget]
2605/// enabled = true
2606/// capacity = 500
2607/// transient_cost = 14
2608/// throttling_cost = 5
2609/// retry_ratio = 0.1
2610/// ```
2611#[cfg(feature = "http-client")]
2612#[derive(Debug, Clone, PartialEq, Deserialize)]
2613pub struct RetryBudgetConfig {
2614    /// Use the retry budget. Default: `true`.
2615    #[serde(default = "default_retry_budget_enabled")]
2616    pub enabled: bool,
2617    /// Tokens in a full bucket. Default: 500.
2618    #[serde(default = "default_retry_budget_capacity")]
2619    pub capacity: u32,
2620    /// Tokens for a retry after a `5xx`, a connect error or a timeout.
2621    /// Default: 14.
2622    #[serde(default = "default_retry_budget_transient_cost")]
2623    pub transient_cost: u32,
2624    /// Tokens for a retry after a `429`. Default: 5.
2625    #[serde(default = "default_retry_budget_throttling_cost")]
2626    pub throttling_cost: u32,
2627    /// The share of requests that can retry a transient failure when the
2628    /// bucket is empty. Default: 0.1 (10 %).
2629    #[serde(default = "default_retry_budget_retry_ratio")]
2630    pub retry_ratio: f64,
2631}
2632
2633#[cfg(feature = "http-client")]
2634const fn default_http_send_deadline_header() -> bool {
2635    true
2636}
2637
2638#[cfg(feature = "http-client")]
2639const fn default_retry_budget_enabled() -> bool {
2640    true
2641}
2642
2643#[cfg(feature = "http-client")]
2644const fn default_retry_budget_capacity() -> u32 {
2645    500
2646}
2647
2648#[cfg(feature = "http-client")]
2649const fn default_retry_budget_transient_cost() -> u32 {
2650    14
2651}
2652
2653#[cfg(feature = "http-client")]
2654const fn default_retry_budget_throttling_cost() -> u32 {
2655    5
2656}
2657
2658#[cfg(feature = "http-client")]
2659const fn default_retry_budget_retry_ratio() -> f64 {
2660    0.1
2661}
2662
2663#[cfg(feature = "http-client")]
2664impl RetryBudgetConfig {
2665    /// Reject a ratio outside `0.0..=1.0` and a cost of zero, which would
2666    /// allow unlimited retries. A disabled budget uses none of these values,
2667    /// so it is not checked.
2668    ///
2669    /// # Errors
2670    ///
2671    /// [`ConfigError::Validation`] that names the bad key.
2672    pub fn validate(&self) -> Result<(), ConfigError> {
2673        if !self.enabled {
2674            return Ok(());
2675        }
2676        if !(0.0..=1.0).contains(&self.retry_ratio) {
2677            return Err(ConfigError::Validation(format!(
2678                "http.client.retry_budget.retry_ratio must be in 0.0..=1.0, got {}",
2679                self.retry_ratio
2680            )));
2681        }
2682        if self.transient_cost == 0 || self.throttling_cost == 0 {
2683            return Err(ConfigError::Validation(
2684                "http.client.retry_budget costs must be 1 or more; set enabled = false \
2685                 to turn the budget off"
2686                    .to_owned(),
2687            ));
2688        }
2689        Ok(())
2690    }
2691}
2692
2693#[cfg(feature = "http-client")]
2694impl Default for RetryBudgetConfig {
2695    fn default() -> Self {
2696        Self {
2697            enabled: default_retry_budget_enabled(),
2698            capacity: default_retry_budget_capacity(),
2699            transient_cost: default_retry_budget_transient_cost(),
2700            throttling_cost: default_retry_budget_throttling_cost(),
2701            retry_ratio: default_retry_budget_retry_ratio(),
2702        }
2703    }
2704}
2705
2706/// `[http.client.adaptive_throttle]`: Google SRE client-side throttling.
2707///
2708/// When a host rejects too many recent attempts (`429` or `503`, or no
2709/// response), the client rejects new attempts to that host locally with
2710/// probability `max(0, (requests − k × accepts) / (requests + 1))`. The
2711/// error is [`ClientError::ThrottledLocally`](crate::http_client::ClientError::ThrottledLocally).
2712///
2713/// ```toml
2714/// [http.client.adaptive_throttle]
2715/// enabled = true
2716/// k = 2.0            # reject once accepts fall below 1/k of requests
2717/// window_secs = 120
2718/// ```
2719#[cfg(feature = "http-client")]
2720#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
2721#[non_exhaustive]
2722pub struct AdaptiveThrottleConfig {
2723    /// Turn the throttle on. Default: `false`.
2724    #[serde(default)]
2725    pub enabled: bool,
2726    /// The multiplier `K`. At least 1.0. Default: 2.0.
2727    #[serde(default = "default_throttle_k")]
2728    pub k: f64,
2729    /// The sliding window, in seconds. At least 1. Default: 120.
2730    #[serde(default = "default_throttle_window_secs")]
2731    pub window_secs: u64,
2732}
2733
2734#[cfg(feature = "http-client")]
2735const fn default_throttle_k() -> f64 {
2736    2.0
2737}
2738
2739#[cfg(feature = "http-client")]
2740const fn default_throttle_window_secs() -> u64 {
2741    120
2742}
2743
2744#[cfg(feature = "http-client")]
2745impl Default for AdaptiveThrottleConfig {
2746    fn default() -> Self {
2747        Self {
2748            enabled: false,
2749            k: default_throttle_k(),
2750            window_secs: default_throttle_window_secs(),
2751        }
2752    }
2753}
2754
2755#[cfg(feature = "http-client")]
2756impl AdaptiveThrottleConfig {
2757    /// Check the settings.
2758    ///
2759    /// # Errors
2760    ///
2761    /// Returns [`ConfigError::Validation`] when `k < 1.0` or
2762    /// `window_secs == 0`.
2763    pub fn validate(&self) -> Result<(), ConfigError> {
2764        if !(self.k >= 1.0 && self.k.is_finite()) {
2765            return Err(ConfigError::Validation(format!(
2766                "http.client.adaptive_throttle.k = {} must be a number >= 1.0: a smaller k \
2767                 rejects calls to a healthy host",
2768                self.k
2769            )));
2770        }
2771        if self.window_secs == 0 {
2772            return Err(ConfigError::Validation(
2773                "http.client.adaptive_throttle.window_secs must be at least 1".to_owned(),
2774            ));
2775        }
2776        Ok(())
2777    }
2778}
2779
2780#[cfg(feature = "http-client")]
2781const fn default_http_timeout_secs() -> u64 {
2782    30
2783}
2784
2785#[cfg(feature = "http-client")]
2786const fn default_http_max_retries() -> u32 {
2787    3
2788}
2789
2790#[cfg(feature = "http-client")]
2791const fn default_http_max_retry_after_secs() -> u64 {
2792    10
2793}
2794
2795#[cfg(feature = "http-client")]
2796const fn default_http_max_backoff_ms() -> u64 {
2797    crate::backoff::DEFAULT_HTTP_MAX_BACKOFF_MS
2798}
2799
2800#[cfg(feature = "http-client")]
2801impl Default for HttpClientConfig {
2802    fn default() -> Self {
2803        Self {
2804            timeout_secs: default_http_timeout_secs(),
2805            max_retries: default_http_max_retries(),
2806            max_retry_after_secs: default_http_max_retry_after_secs(),
2807            max_backoff_ms: default_http_max_backoff_ms(),
2808            base_urls: std::collections::HashMap::new(),
2809            send_deadline_header: default_http_send_deadline_header(),
2810            retry_budget: RetryBudgetConfig::default(),
2811            adaptive_throttle: AdaptiveThrottleConfig::default(),
2812        }
2813    }
2814}
2815
2816impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
2817    type Rejection = crate::AutumnError;
2818
2819    async fn from_request_parts(
2820        _parts: &mut http::request::Parts,
2821        state: &crate::AppState,
2822    ) -> Result<Self, Self::Rejection> {
2823        state
2824            .extension::<Self>()
2825            .as_deref()
2826            .cloned()
2827            .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
2828    }
2829}
2830
2831/// Real-time channel backend selection.
2832#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2833#[serde(rename_all = "snake_case")]
2834pub enum ChannelBackend {
2835    /// In-process Tokio broadcast channels. Default, zero config.
2836    #[serde(alias = "local", alias = "memory")]
2837    #[default]
2838    InProcess,
2839    /// Redis pub/sub fan-out across application replicas.
2840    Redis,
2841}
2842
2843impl ChannelBackend {
2844    /// Parse an environment variable value for channel backend selection.
2845    #[must_use]
2846    pub fn from_env_value(value: &str) -> Option<Self> {
2847        match value.trim().to_ascii_lowercase().as_str() {
2848            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
2849            "redis" => Some(Self::Redis),
2850            _ => None,
2851        }
2852    }
2853}
2854
2855/// `[realtime]` — limits for WebSocket connections (issue #3065).
2856///
2857/// The limits apply to every `#[ws]` route and to
2858/// [`crate::ws::WebSocketUpgrade`]. Every key is optional. When a key is not
2859/// set, the socket applies no limit for it. The `prod` profile sets safe
2860/// values for the message size, ping and idle keys.
2861///
2862/// ```toml
2863/// [realtime]
2864/// max_connections = 5000
2865/// max_message_bytes = 1048576
2866/// ping_interval_ms = 30000
2867/// idle_timeout_ms = 120000
2868/// ```
2869#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
2870pub struct RealtimeConfig {
2871    /// Maximum open WebSocket connections per process. An upgrade above the
2872    /// limit gets `503` with `Retry-After`. `None` or `0` disables it.
2873    #[serde(default)]
2874    pub max_connections: Option<usize>,
2875
2876    /// Maximum size of one received message, in bytes. A larger message
2877    /// closes the socket with code `1009`. `None` keeps the 64 MiB default.
2878    #[serde(default)]
2879    pub max_message_bytes: Option<usize>,
2880
2881    /// Interval between server pings. `None` or `0` disables pings.
2882    #[serde(default)]
2883    pub ping_interval_ms: Option<u64>,
2884
2885    /// Close a socket with code `1001` when no complete message arrives for
2886    /// this long. Pings and pongs count. `None` or `0` disables it.
2887    #[serde(default)]
2888    pub idle_timeout_ms: Option<u64>,
2889}
2890
2891impl RealtimeConfig {
2892    /// Reject values the server cannot use.
2893    ///
2894    /// # Errors
2895    ///
2896    /// Returns [`ConfigError::Validation`] when `max_message_bytes` is `0`.
2897    pub fn validate(&self) -> Result<(), ConfigError> {
2898        if self.max_message_bytes == Some(0) {
2899            return Err(ConfigError::Validation(
2900                "realtime.max_message_bytes must be greater than zero".to_owned(),
2901            ));
2902        }
2903        Ok(())
2904    }
2905}
2906
2907/// Real-time channel runtime configuration.
2908#[derive(Debug, Clone, Deserialize)]
2909pub struct ChannelConfig {
2910    /// Runtime backend selection.
2911    #[serde(default)]
2912    pub backend: ChannelBackend,
2913    /// Per-topic broadcast ring buffer capacity.
2914    #[serde(default = "default_channel_capacity")]
2915    pub capacity: usize,
2916    /// Per-topic replay ring buffer capacity (`N`).
2917    ///
2918    /// Number of most-recent events retained per topic for `Last-Event-ID`
2919    /// replay via [`crate::sse::stream_resumable`]. Memory is `O(N)` per topic
2920    /// regardless of throughput.
2921    #[serde(default = "default_channel_replay_buffer")]
2922    pub replay_buffer: usize,
2923    /// Redis backend options.
2924    #[serde(default)]
2925    pub redis: ChannelRedisConfig,
2926}
2927
2928impl Default for ChannelConfig {
2929    fn default() -> Self {
2930        Self {
2931            backend: ChannelBackend::default(),
2932            capacity: default_channel_capacity(),
2933            replay_buffer: default_channel_replay_buffer(),
2934            redis: ChannelRedisConfig::default(),
2935        }
2936    }
2937}
2938
2939/// Redis channel backend configuration.
2940#[derive(Debug, Clone, Deserialize)]
2941pub struct ChannelRedisConfig {
2942    /// Redis URL used when `channels.backend = "redis"`.
2943    #[serde(default)]
2944    pub url: Option<String>,
2945    /// Redis pub/sub channel prefix.
2946    #[serde(default = "default_channels_redis_prefix")]
2947    pub key_prefix: String,
2948}
2949
2950impl Default for ChannelRedisConfig {
2951    fn default() -> Self {
2952        Self {
2953            url: None,
2954            key_prefix: default_channels_redis_prefix(),
2955        }
2956    }
2957}
2958
2959const fn default_channel_capacity() -> usize {
2960    32
2961}
2962
2963const fn default_channel_replay_buffer() -> usize {
2964    256
2965}
2966
2967fn default_channels_redis_prefix() -> String {
2968    "autumn:channels".to_owned()
2969}
2970
2971// ── Cluster configuration ────────────────────────────────────────────────────
2972
2973/// Embedded self-clustering control plane (`[cluster]` section, issue #1762).
2974///
2975/// Off by default. When enabled, the node binds a small authenticated gossip
2976/// listener, discovers the peers named in `seed_peers`, and exposes a
2977/// cluster-wide counter through
2978/// [`ClusterHandle`](crate::cluster::ClusterHandle). No external coordination
2979/// service is involved — see `docs/guide/clustering.md`.
2980///
2981/// The transport is **authenticated (HMAC-SHA256), not encrypted**: run it on a
2982/// trusted network.
2983///
2984/// Unrelated to [`crate::sharding`]'s database-"cluster" vocabulary.
2985///
2986/// # Examples
2987///
2988/// ```toml
2989/// [cluster]
2990/// enabled = true
2991/// secret = "a-shared-secret-at-least-16-bytes"
2992/// bind_addr = "0.0.0.0:7946"
2993/// advertise_addr = "10.0.0.4:7946"
2994/// seed_peers = ["10.0.0.5:7946"]
2995/// ```
2996#[derive(Debug, Clone, Deserialize)]
2997pub struct ClusterConfig {
2998    /// Master switch. When `false` (the default) nothing is bound, nothing is
2999    /// spawned, and `state.extension::<ClusterHandle>()` is `None`.
3000    #[serde(default)]
3001    pub enabled: bool,
3002
3003    /// Shared secret every member signs its frames with (minimum 16 bytes).
3004    ///
3005    /// Stored as a [`secrecy::SecretString`] so the raw value is redacted from
3006    /// `Debug` output and zeroized on drop. Call
3007    /// [`secrecy::ExposeSecret::expose_secret`] at the point of use.
3008    #[serde(default)]
3009    pub secret: Option<secrecy::SecretString>,
3010
3011    /// Cluster name. Signed into every frame, so two clusters that share a
3012    /// secret still refuse each other's traffic.
3013    #[serde(default = "default_cluster_name")]
3014    pub cluster_name: String,
3015
3016    /// Address the cluster listener binds. Port `0` takes an OS-assigned
3017    /// ephemeral port, readable back through `ClusterHandle::local_addr`.
3018    #[serde(default = "default_cluster_bind_addr")]
3019    pub bind_addr: String,
3020
3021    /// Address advertised to peers when it differs from `bind_addr` (NAT,
3022    /// container port mapping). Defaults to the bound address.
3023    #[serde(default)]
3024    pub advertise_addr: Option<String>,
3025
3026    /// Peer addresses to dial on startup. One reachable seed is enough.
3027    #[serde(default)]
3028    pub seed_peers: Vec<String>,
3029
3030    /// Explicit node id. Entropy-derived when absent (never hostname-derived).
3031    #[serde(default)]
3032    pub node_id: Option<String>,
3033
3034    /// Base interval between state pushes, in milliseconds. The push is also
3035    /// the heartbeat, so this is the failure-detector's sampling rate.
3036    #[serde(default = "default_cluster_push_interval_ms")]
3037    pub push_interval_ms: u64,
3038
3039    /// How long without a push before a peer is suspected, in milliseconds.
3040    /// Must be at least three push intervals (anti-flap hysteresis).
3041    #[serde(default = "default_cluster_suspicion_timeout_ms")]
3042    pub suspicion_timeout_ms: u64,
3043}
3044
3045fn default_cluster_name() -> String {
3046    "autumn".to_owned()
3047}
3048
3049fn default_cluster_bind_addr() -> String {
3050    "127.0.0.1:0".to_owned()
3051}
3052
3053const fn default_cluster_push_interval_ms() -> u64 {
3054    500
3055}
3056
3057const fn default_cluster_suspicion_timeout_ms() -> u64 {
3058    2_500
3059}
3060
3061/// Shortest secret accepted when `[cluster] enabled = true`.
3062pub(crate) const MIN_CLUSTER_SECRET_LEN: usize = 16;
3063
3064/// Shortest push interval accepted, in milliseconds.
3065pub(crate) const MIN_CLUSTER_PUSH_INTERVAL_MS: u64 = 10;
3066
3067/// The suspicion timeout must be at least this many push intervals.
3068pub(crate) const MIN_CLUSTER_SUSPICION_MULTIPLE: u64 = 3;
3069
3070/// Longest `node_id` / `cluster_name` accepted, in bytes.
3071///
3072/// Both travel in every frame and are covered by the MAC, so the bound keeps
3073/// the fixed overhead of a state push small and predictable.
3074pub(crate) const MAX_CLUSTER_IDENT_LEN: usize = 64;
3075
3076/// Separator between node id and incarnation in a counter cell key
3077/// (`"{node_id}#{incarnation}"`). Reserved: an id containing it would make
3078/// cell keys ambiguous, so validation refuses one.
3079pub(crate) const CLUSTER_CELL_KEY_SEPARATOR: char = '#';
3080
3081/// Validate one identity string (`cluster.node_id`, `cluster.cluster_name`).
3082///
3083/// `field` is the dotted config path, used verbatim in the message so an
3084/// operator can fix the offending key without reading the source.
3085fn validate_cluster_ident(field: &str, value: &str) -> Result<(), ConfigError> {
3086    if value.trim().is_empty() {
3087        return Err(ConfigError::Validation(format!(
3088            "{field} must not be empty: it identifies this node (or its cluster) in every frame"
3089        )));
3090    }
3091    let len = value.len();
3092    if len > MAX_CLUSTER_IDENT_LEN {
3093        return Err(ConfigError::Validation(format!(
3094            "{field} must be at most {MAX_CLUSTER_IDENT_LEN} bytes, got {len} ({value:?}); it \
3095             travels in every cluster frame and is covered by the MAC"
3096        )));
3097    }
3098    if value.contains(CLUSTER_CELL_KEY_SEPARATOR) {
3099        return Err(ConfigError::Validation(format!(
3100            "{field} must not contain {CLUSTER_CELL_KEY_SEPARATOR:?} ({value:?}): it separates \
3101             the node id from the incarnation in counter cell keys, and an id containing it \
3102             would make two different cells collide"
3103        )));
3104    }
3105    Ok(())
3106}
3107
3108/// Parse a cluster address, rejecting anything that is not a `host:port` pair
3109/// with an IP literal (hostnames are never resolved).
3110fn parse_cluster_addr(field: &str, value: &str) -> Result<std::net::SocketAddr, ConfigError> {
3111    value.parse::<std::net::SocketAddr>().map_err(|error| {
3112        ConfigError::Validation(format!(
3113            "{field} must be a socket address of the form host:port with an IP literal \
3114             (hostnames are not resolved), got {value:?}: {error}"
3115        ))
3116    })
3117}
3118
3119/// Reject port `0` on an address somebody has to *dial*.
3120///
3121/// Port `0` is only meaningful on `bind_addr`, where it means "let the OS pick"
3122/// and the node then advertises the port it actually got. Everywhere else it is
3123/// undialable: a peer would connect to port 0 and fail forever, and the mistake
3124/// looks exactly like a network problem from the other side.
3125fn reject_ephemeral_cluster_port(
3126    field: &str,
3127    value: &str,
3128    addr: std::net::SocketAddr,
3129) -> Result<(), ConfigError> {
3130    if addr.port() == 0 {
3131        return Err(ConfigError::Validation(format!(
3132            "{field} is {value:?}, which no peer can dial: port 0 means \"any free port\" and is \
3133             only meaningful on cluster.bind_addr, where the node advertises the port it was \
3134             actually given (see docs/guide/clustering.md)"
3135        )));
3136    }
3137    Ok(())
3138}
3139
3140impl Default for ClusterConfig {
3141    fn default() -> Self {
3142        Self {
3143            enabled: false,
3144            secret: None,
3145            cluster_name: default_cluster_name(),
3146            bind_addr: default_cluster_bind_addr(),
3147            advertise_addr: None,
3148            seed_peers: Vec::new(),
3149            node_id: None,
3150            push_interval_ms: default_cluster_push_interval_ms(),
3151            suspicion_timeout_ms: default_cluster_suspicion_timeout_ms(),
3152        }
3153    }
3154}
3155
3156impl ClusterConfig {
3157    /// Fail fast on a `[cluster]` section that would boot an insecure or
3158    /// flapping cluster.
3159    ///
3160    /// Checked when `enabled` — these are about a node that will really bind
3161    /// and gossip:
3162    /// - `secret` must be present and at least 16 bytes. There is no lenient
3163    ///   unauthenticated mode.
3164    /// - The address peers are told to dial (`advertise_addr`, or `bind_addr`
3165    ///   when it is unset) must not be a wildcard: nobody can dial `0.0.0.0`.
3166    ///
3167    /// Checked always, enabled or not — a section that is wrong is wrong
3168    /// before the switch is flipped:
3169    /// - `push_interval_ms` must be at least 10ms.
3170    /// - `suspicion_timeout_ms` must be at least 3 × `push_interval_ms`: the
3171    ///   anti-flap hysteresis, below which one delayed push evicts a healthy
3172    ///   peer.
3173    /// - `bind_addr`, `advertise_addr` and every `seed_peers` entry must parse
3174    ///   as a [`std::net::SocketAddr`] (IP literal — hostnames are not
3175    ///   resolved).
3176    /// - `cluster_name`, and `node_id` when set, must be non-empty, at most 64
3177    ///   bytes, and free of the `#` counter cell-key separator.
3178    ///
3179    /// # Errors
3180    ///
3181    /// Returns [`ConfigError::Validation`] describing the first violated rule.
3182    pub fn validate(&self) -> Result<(), ConfigError> {
3183        // Operational rules: only meaningful for a node that will actually
3184        // bind and gossip.
3185        if self.enabled {
3186            self.validate_secret()?;
3187        }
3188
3189        if self.push_interval_ms < MIN_CLUSTER_PUSH_INTERVAL_MS {
3190            return Err(ConfigError::Validation(format!(
3191                "cluster.push_interval_ms must be at least {MIN_CLUSTER_PUSH_INTERVAL_MS}ms, \
3192                 got {}",
3193                self.push_interval_ms
3194            )));
3195        }
3196        // Saturating: a push interval near u64::MAX would overflow the
3197        // multiply, and an overflow must not turn into an accidental pass.
3198        let min_suspicion = self
3199            .push_interval_ms
3200            .saturating_mul(MIN_CLUSTER_SUSPICION_MULTIPLE);
3201        if self.suspicion_timeout_ms < min_suspicion {
3202            return Err(ConfigError::Validation(format!(
3203                "cluster.suspicion_timeout_ms ({}) must be at least \
3204                 {MIN_CLUSTER_SUSPICION_MULTIPLE}x cluster.push_interval_ms ({}), i.e. at least \
3205                 {min_suspicion}ms: below that ratio one delayed push evicts a healthy peer and \
3206                 the view flaps",
3207                self.suspicion_timeout_ms, self.push_interval_ms
3208            )));
3209        }
3210
3211        validate_cluster_ident("cluster.cluster_name", &self.cluster_name)?;
3212        if let Some(node_id) = self.node_id.as_deref() {
3213            validate_cluster_ident("cluster.node_id", node_id)?;
3214        }
3215
3216        // `bind_addr` may keep port 0 — that is the documented "ephemeral bind"
3217        // spelling, read back through `ClusterHandle::local_addr`.
3218        let bind_addr = parse_cluster_addr("cluster.bind_addr", &self.bind_addr)?;
3219        let advertise_addr = match self.advertise_addr.as_deref() {
3220            Some(addr) => {
3221                let parsed = parse_cluster_addr("cluster.advertise_addr", addr)?;
3222                reject_ephemeral_cluster_port("cluster.advertise_addr", addr, parsed)?;
3223                parsed
3224            }
3225            None => bind_addr,
3226        };
3227        for (index, peer) in self.seed_peers.iter().enumerate() {
3228            let field = format!("cluster.seed_peers[{index}]");
3229            let parsed = parse_cluster_addr(&field, peer)?;
3230            reject_ephemeral_cluster_port(&field, peer, parsed)?;
3231        }
3232
3233        // A wildcard bind is legal, advertising one is not: peers copy the
3234        // advertised address out of the pushed state and dial it verbatim.
3235        if self.enabled && advertise_addr.ip().is_unspecified() {
3236            let source = if self.advertise_addr.is_some() {
3237                "cluster.advertise_addr"
3238            } else {
3239                "cluster.bind_addr"
3240            };
3241            return Err(ConfigError::Validation(format!(
3242                "{source} advertises {advertise_addr}, which no peer can dial: binding a \
3243                 wildcard address is fine, but it requires an explicit, non-wildcard \
3244                 cluster.advertise_addr (see docs/guide/clustering.md)"
3245            )));
3246        }
3247
3248        Ok(())
3249    }
3250
3251    /// The shared HMAC key: required, and long enough to be a key.
3252    fn validate_secret(&self) -> Result<(), ConfigError> {
3253        let Some(secret) = self.secret.as_ref() else {
3254            return Err(ConfigError::Validation(
3255                "cluster.secret is required when cluster.enabled = true: the cluster transport \
3256                 is authenticated (HMAC-SHA256) and has no unauthenticated mode — set it with \
3257                 AUTUMN_CLUSTER__SECRET"
3258                    .to_owned(),
3259            ));
3260        };
3261        let len = secrecy::ExposeSecret::expose_secret(secret).len();
3262        if len < MIN_CLUSTER_SECRET_LEN {
3263            return Err(ConfigError::Validation(format!(
3264                "cluster.secret must be at least {MIN_CLUSTER_SECRET_LEN} bytes, got {len}: a \
3265                 short shared key is a guessable one, and every member signs every frame with it"
3266            )));
3267        }
3268        Ok(())
3269    }
3270}
3271
3272// ── Cache configuration ──────────────────────────────────────────────────────
3273
3274/// Cache backend selection for `#[cached]` and `CacheResponseLayer`.
3275#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
3276#[serde(rename_all = "lowercase")]
3277#[non_exhaustive]
3278pub enum CacheBackend {
3279    /// In-process Moka cache (default). Each replica has an independent store.
3280    #[default]
3281    Memory,
3282    /// Shared Redis cache. Invalidations propagate across all replicas.
3283    Redis,
3284}
3285
3286impl CacheBackend {
3287    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
3288        match value.trim().to_ascii_lowercase().as_str() {
3289            "memory" => Some(Self::Memory),
3290            "redis" => Some(Self::Redis),
3291            _ => None,
3292        }
3293    }
3294}
3295
3296/// Configuration for the shared application cache.
3297///
3298/// Placed in `autumn.toml` under `[cache]`.
3299///
3300/// # Examples
3301///
3302/// ```toml
3303/// [cache]
3304/// backend = "redis"
3305///
3306/// [cache.redis]
3307/// url = "redis://redis:6379"
3308/// key_prefix = "myapp:cache"
3309/// ```
3310#[derive(Debug, Clone, Default, serde::Deserialize)]
3311pub struct CacheConfig {
3312    /// Active cache backend.
3313    #[serde(default)]
3314    pub backend: CacheBackend,
3315
3316    /// Redis backend options.
3317    #[serde(default)]
3318    pub redis: CacheRedisConfig,
3319}
3320
3321impl CacheConfig {
3322    /// Returns `true` when the memory (Moka) backend is selected.
3323    #[must_use]
3324    pub fn is_memory(&self) -> bool {
3325        self.backend == CacheBackend::Memory
3326    }
3327
3328    /// Returns `true` when the Redis backend is selected.
3329    #[must_use]
3330    pub fn is_redis(&self) -> bool {
3331        self.backend == CacheBackend::Redis
3332    }
3333}
3334
3335/// Redis cache backend configuration.
3336#[derive(Debug, Clone, serde::Deserialize)]
3337pub struct CacheRedisConfig {
3338    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
3339    #[serde(default)]
3340    pub url: Option<String>,
3341
3342    /// Prefix for all cache keys stored in Redis.
3343    #[serde(default = "default_cache_redis_key_prefix")]
3344    pub key_prefix: String,
3345}
3346
3347impl Default for CacheRedisConfig {
3348    fn default() -> Self {
3349        Self {
3350            url: None,
3351            key_prefix: default_cache_redis_key_prefix(),
3352        }
3353    }
3354}
3355
3356fn default_cache_redis_key_prefix() -> String {
3357    "autumn:cache".to_owned()
3358}
3359
3360/// Scheduled task coordination backend selection.
3361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
3362#[serde(rename_all = "snake_case")]
3363pub enum SchedulerBackend {
3364    /// Per-process scheduler timers. This preserves existing single-replica behavior.
3365    #[serde(alias = "local", alias = "memory")]
3366    #[default]
3367    InProcess,
3368    /// Fleet coordination with a Postgres tick table (issue #3052).
3369    ///
3370    /// Each fleet tick is one row. Only the replica that inserts the row runs
3371    /// the tick.
3372    Postgres,
3373    /// Single-host coordination with a `SQLite` lease table (issue #1907).
3374    ///
3375    /// Each `(task, tick)` is leased in the app's own database file, so several
3376    /// processes on one host elect exactly one leader per tick. A `SQLite`
3377    /// deployment is single-host by definition.
3378    #[serde(alias = "single_host")]
3379    Sqlite,
3380}
3381
3382impl SchedulerBackend {
3383    /// Parse an environment variable value for scheduler backend selection.
3384    #[must_use]
3385    pub fn from_env_value(value: &str) -> Option<Self> {
3386        match value.trim().to_ascii_lowercase().as_str() {
3387            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
3388            "postgres" | "postgresql" => Some(Self::Postgres),
3389            "sqlite" | "single_host" | "single-host" => Some(Self::Sqlite),
3390            _ => None,
3391        }
3392    }
3393
3394    /// Whether this CONFIGURED backend coordinates across a fleet of
3395    /// replicas, rather than a single process.
3396    ///
3397    /// Named so call sites (issue #1864) — like the ACME renewal spawn site,
3398    /// which must ask this even when it had to fall back to an in-process
3399    /// [`crate::scheduler::SchedulerCoordinator`] after a construction error —
3400    /// read as intent rather than an inline enum match. See
3401    /// [`crate::scheduler::SchedulerCoordinator::is_fleet_distributed`] for
3402    /// the equivalent query on an actually-built coordinator instance.
3403    #[must_use]
3404    pub const fn is_fleet_distributed(self) -> bool {
3405        matches!(self, Self::Postgres | Self::Sqlite)
3406    }
3407}
3408
3409/// Process role: which slice of the framework runtime this replica runs.
3410///
3411/// The same binary can be deployed under different roles so a fleet can scale
3412/// its HTTP tier independently of its background-work tier. The role is chosen
3413/// by config (`role = "..."`) or the `AUTUMN_ROLE` env var only — application
3414/// code never changes. The default, [`Combined`](ProcessRole::Combined),
3415/// preserves today's single-process behavior exactly.
3416///
3417/// - [`Combined`](ProcessRole::Combined): serves HTTP **and** runs job workers
3418///   + the cron scheduler (default).
3419/// - [`Web`](ProcessRole::Web): serves HTTP and can still **enqueue** jobs, but
3420///   runs no `#[job]` worker loops and no `#[scheduled]`/cron scheduler.
3421/// - [`Worker`](ProcessRole::Worker): runs job workers + the cron scheduler and
3422///   does **not** serve user routes, but still binds the HTTP listener to serve
3423///   only the liveness/readiness probes and the actuator (so orchestrators can
3424///   supervise it and `/actuator/jobs` works).
3425#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
3426#[serde(rename_all = "snake_case")]
3427pub enum ProcessRole {
3428    /// Serve HTTP and run background workers + scheduler (default; unchanged
3429    /// single-process behavior).
3430    #[serde(
3431        alias = "all",
3432        alias = "combined",
3433        alias = "web_and_worker",
3434        alias = "server_and_worker"
3435    )]
3436    #[default]
3437    Combined,
3438    /// Serve HTTP (and enqueue jobs) only — no worker loops, no scheduler.
3439    #[serde(alias = "server", alias = "http")]
3440    Web,
3441    /// Run workers + scheduler only — probe/actuator HTTP only, no user routes.
3442    #[serde(alias = "jobs", alias = "worker_only")]
3443    Worker,
3444}
3445
3446impl ProcessRole {
3447    /// Parse an environment variable / flag value for process-role selection.
3448    ///
3449    /// Accepts (case-insensitive, trimmed): `combined`/`all`, `web`/`server`/
3450    /// `http`, `worker`/`jobs`. Returns `None` for anything else so callers can
3451    /// warn and keep the default.
3452    #[must_use]
3453    pub fn from_env_value(value: &str) -> Option<Self> {
3454        match value.trim().to_ascii_lowercase().as_str() {
3455            "combined" | "all" | "web_and_worker" | "server_and_worker" => Some(Self::Combined),
3456            "web" | "server" | "http" => Some(Self::Web),
3457            "worker" | "jobs" | "worker_only" => Some(Self::Worker),
3458            _ => None,
3459        }
3460    }
3461
3462    /// Stable lowercase identifier for the role (round-trips `from_env_value`).
3463    #[must_use]
3464    pub const fn as_str(self) -> &'static str {
3465        match self {
3466            Self::Combined => "combined",
3467            Self::Web => "web",
3468            Self::Worker => "worker",
3469        }
3470    }
3471
3472    /// Whether this role serves user HTTP routes ([`Combined`](Self::Combined)
3473    /// or [`Web`](Self::Web)).
3474    #[must_use]
3475    pub const fn serves_http(self) -> bool {
3476        matches!(self, Self::Combined | Self::Web)
3477    }
3478
3479    /// Whether this role runs background job workers and the cron scheduler
3480    /// ([`Combined`](Self::Combined) or [`Worker`](Self::Worker)).
3481    #[must_use]
3482    pub const fn runs_workers(self) -> bool {
3483        matches!(self, Self::Combined | Self::Worker)
3484    }
3485}
3486
3487/// Whether a `role` / `jobs.backend` combination is invalid because a split
3488/// (web/worker) role sits on a non-durable jobs backend.
3489///
3490/// A split role runs the HTTP tier and the job/scheduler tier in **separate
3491/// processes**, so it needs a jobs backend the two processes can share. Only the
3492/// recognized durable backends [`start_runtime`](crate::job::start_runtime)
3493/// dispatches to durably — exactly `"postgres"`, `"redis"`, or `"sqlite"` —
3494/// qualify. The `"sqlite"` queue is a table in the app's own database file, so
3495/// two processes on the **same host** share it; a `SQLite` deployment is
3496/// single-host by definition, and a multi-replica `SQLite` topology is already
3497/// refused at boot (issue #1907). Every
3498/// other value (the in-process `"local"` queue, a typo like `"postgresql"`, or a
3499/// blank backend) falls through to the per-process local runtime, where a
3500/// [`Web`](ProcessRole::Web) replica would enqueue into a queue no separate
3501/// worker can drain and a [`Worker`](ProcessRole::Worker) replica's queue starts
3502/// empty. The match is intentionally exact (no trim/case-fold) so this guard and
3503/// `start_runtime`'s dispatch agree precisely on which backends are durable.
3504///
3505/// The combined role is always valid because it enqueues and drains in one
3506/// process. Returns `true` when the combination is **invalid**.
3507///
3508/// This answers only the *backend shape*. `sqlite` also needs a file-backed
3509/// database to be shareable at all — see
3510/// [`split_role_requires_file_backed_sqlite`], which the boot guard checks
3511/// alongside this one.
3512#[must_use]
3513pub fn split_role_requires_durable_backend(role: ProcessRole, jobs_backend: &str) -> bool {
3514    role != ProcessRole::Combined && !matches!(jobs_backend, "postgres" | "redis" | "sqlite")
3515}
3516
3517/// Whether a `SQLite` target is in memory rather than a file.
3518///
3519/// An in-memory database is private to the process that opened it — even the
3520/// `cache=shared` spellings, which share only within one process. Nothing
3521/// written by one process is visible to another.
3522///
3523/// Deliberately self-contained rather than calling `crate::db`'s equivalent:
3524/// that one is gated on the `sqlite` feature, and this has to answer for
3525/// `autumn doctor`, which reads a `sqlite://` config from a Postgres build.
3526/// The two must agree on the spellings; see
3527/// `crate::db::sqlite_target_is_any_in_memory`.
3528#[must_use]
3529pub fn is_in_memory_sqlite_target(url: &str) -> bool {
3530    if url.starts_with("file:") {
3531        return url == "file::memory:"
3532            || url.starts_with("file::memory:?")
3533            || url.contains("mode=memory");
3534    }
3535    let target = url
3536        .strip_prefix("sqlite://")
3537        .or_else(|| url.strip_prefix("sqlite:"))
3538        .unwrap_or(url);
3539    // An empty target is in memory too: a bare `sqlite://` or `sqlite:` names
3540    // no file, and `crate::db::normalize_sqlite_target` turns it into
3541    // `:memory:` before handing it to `sqlite3_open`.
3542    target.is_empty()
3543        || target == ":memory:"
3544        || target == "file::memory:"
3545        || target.starts_with("file::memory:?")
3546        || target.contains("mode=memory")
3547}
3548
3549/// Whether a split role on the `SQLite` jobs backend is invalid because the
3550/// database is in memory (issue #1907).
3551///
3552/// The `sqlite` queue is durable enough for a split web/worker topology only
3553/// because both processes open the same **file**. Against an in-memory target
3554/// each process gets its own database, so the web replica enqueues into a queue
3555/// the worker can never see and every job is silently stranded — the exact
3556/// failure [`split_role_requires_durable_backend`] exists to prevent, which it
3557/// cannot see because it is given only the backend name.
3558///
3559/// `None` for `database_url` answers `false`: a `sqlite` backend with no
3560/// database configured fails earlier, with its own error.
3561#[must_use]
3562pub fn split_role_requires_file_backed_sqlite(
3563    role: ProcessRole,
3564    jobs_backend: &str,
3565    database_url: Option<&str>,
3566) -> bool {
3567    role != ProcessRole::Combined
3568        && jobs_backend == "sqlite"
3569        && database_url.is_some_and(is_in_memory_sqlite_target)
3570}
3571
3572/// `[retention]` — one retention window per framework-owned dataset
3573/// (issue #1605).
3574///
3575/// Autumn creates and fills tables and stores the application never asked
3576/// for: the job queue and its tracking records, idempotency replay records,
3577/// sticky experiment assignments, webhook replay markers, sessions, and audit
3578/// archives. Being batteries-included means owning their lifecycle. This
3579/// section is the single place an operator declares how long each of those
3580/// datasets is kept; the framework then enforces it on a recurring in-process
3581/// sweep with no external cron.
3582///
3583/// Every window defaults to `None`, which means **exactly today's behavior**
3584/// for that dataset — nothing is swept and no sweep task is even registered
3585/// unless at least one window is set.
3586///
3587/// # `autumn.toml` example
3588///
3589/// ```toml
3590/// [retention]
3591/// sweep_interval         = "1h"    # how often the sweep runs (default "1h")
3592/// job_history            = "90d"   # terminal rows in `autumn_jobs`
3593/// commit_hooks           = "30d"   # terminal `#[after_commit]` hook rows
3594/// job_tracking           = "7d"    # `autumn_job_tracking` records
3595/// idempotency            = "2d"    # stored idempotency responses
3596/// experiment_assignments = "365d"  # `autumn_experiment_assignments`
3597/// webhook_replay         = "3d"    # inbound webhook replay markers
3598/// sessions               = "30d"   # server-side session records
3599/// audit_archives         = "400d"  # JSONL audit archive entries
3600/// custom_domains         = "30d"   # tenant domains never verified since
3601/// ```
3602///
3603/// Durations use the same syntax as `#[scheduled(every = ...)]`: `s`/`m`/`h`/
3604/// `d`, optionally compound (`"1h 30m"`).
3605///
3606/// See `docs/guide/data-retention.md` for the full dataset table, the
3607/// precedence rule against `jobs.tracking.ttl_secs` / `idempotency.ttl_secs`,
3608/// and the `autumn db retention` CLI.
3609#[derive(Debug, Clone, Deserialize)]
3610pub struct RetentionConfig {
3611    /// How often the framework retention sweep runs. Default: `"1h"`.
3612    ///
3613    /// Only consulted when at least one dataset window is set — with none set
3614    /// no sweep task is registered at all.
3615    #[serde(default = "default_retention_sweep_interval")]
3616    pub sweep_interval: String,
3617
3618    /// Terminal (`completed`/`failed`/`discarded`) rows in `autumn_jobs`,
3619    /// measured from `finished_at`. Unset (default): job history is kept
3620    /// forever.
3621    ///
3622    /// Live rows — enqueued, running, or retrying — are never touched
3623    /// regardless of age, and neither is a row still holding a
3624    /// `#[job(unique, unique_for_ms = ...)]` dedup key. Note that `failed` is
3625    /// also the dead-letter state a `autumn jobs retry` replays from, so a
3626    /// window here bounds how long a dead letter stays replayable; see
3627    /// `docs/guide/data-retention.md`.
3628    #[serde(default)]
3629    pub job_history: Option<String>,
3630
3631    /// Terminal rows in the `#[after_commit]` hook queue,
3632    /// `autumn_repository_commit_hooks`, measured from `finished_at`. Unset
3633    /// (default): kept forever.
3634    #[serde(default)]
3635    pub commit_hooks: Option<String>,
3636
3637    /// `autumn_job_tracking` progress/result records, measured from
3638    /// `updated_at`. Unset (default): rows live until their
3639    /// `jobs.tracking.ttl_secs` expiry, which the existing sweep already
3640    /// enforces.
3641    ///
3642    /// When both are set the **shorter** bound wins; see
3643    /// `docs/guide/data-retention.md`.
3644    #[serde(default)]
3645    pub job_tracking: Option<String>,
3646
3647    /// Stored idempotency-key responses, measured from when they were
3648    /// written. Unset (default): records live for `idempotency.ttl_secs`.
3649    ///
3650    /// Idempotency backends expire their own records, so this window is
3651    /// enforced by capping the record TTL at write time rather than by a
3652    /// sweep. When both are set the **shorter** bound wins.
3653    #[serde(default)]
3654    pub idempotency: Option<String>,
3655
3656    /// Sticky `autumn_experiment_assignments` rows, measured from
3657    /// `assigned_at`. Unset (default): assignments are kept forever.
3658    #[serde(default)]
3659    pub experiment_assignments: Option<String>,
3660
3661    /// Inbound webhook replay markers, measured from when they were
3662    /// recorded. Unset (default): markers live for the endpoint's
3663    /// `replay_window_secs`.
3664    ///
3665    /// Enforced by capping the marker TTL, as for [`Self::idempotency`].
3666    #[serde(default)]
3667    pub webhook_replay: Option<String>,
3668
3669    /// Server-side session records, measured from their last write. Unset
3670    /// (default): the session backend's own expiry applies (which, for the
3671    /// in-memory store, is no expiry at all).
3672    #[serde(default)]
3673    pub sessions: Option<String>,
3674
3675    /// Entries in the JSONL audit archive, measured from each event's
3676    /// `timestamp`. Unset (default): audit archives are kept forever.
3677    ///
3678    /// Enforced by rewriting the archive file in place (atomically), so it
3679    /// only applies to sinks that support purging — today
3680    /// [`crate::audit::JsonlFileAuditSink`].
3681    #[serde(default)]
3682    pub audit_archives: Option<String>,
3683
3684    /// Tenant custom-domain registrations that never reached DNS
3685    /// verification, measured from registration. Unset (default): a pending
3686    /// registration is kept until the app offboards it.
3687    ///
3688    /// Never touches a domain that verified: an active domain is live
3689    /// configuration, not aged data. Orphaned certificates — a stored pair for
3690    /// a hostname no longer registered — are pruned by the same sweep
3691    /// regardless of this window, since there is no record left to age.
3692    #[serde(default)]
3693    pub custom_domains: Option<String>,
3694}
3695
3696impl Default for RetentionConfig {
3697    fn default() -> Self {
3698        Self {
3699            sweep_interval: default_retention_sweep_interval(),
3700            job_history: None,
3701            commit_hooks: None,
3702            job_tracking: None,
3703            idempotency: None,
3704            experiment_assignments: None,
3705            webhook_replay: None,
3706            sessions: None,
3707            audit_archives: None,
3708            custom_domains: None,
3709        }
3710    }
3711}
3712
3713fn default_retention_sweep_interval() -> String {
3714    "1h".to_owned()
3715}
3716
3717impl RetentionConfig {
3718    /// Every `(config key, window)` pair, in the order the CLI reports them.
3719    ///
3720    /// The single source of truth that keeps the config surface, the dataset
3721    /// registry in [`crate::data_retention`], the CLI report, and the docs
3722    /// table from drifting apart: adding a field here without adding the
3723    /// matching dataset fails a test in `crate::data_retention`.
3724    #[must_use]
3725    pub fn windows(&self) -> [(&'static str, Option<&str>); 9] {
3726        [
3727            ("job_history", self.job_history.as_deref()),
3728            ("commit_hooks", self.commit_hooks.as_deref()),
3729            ("job_tracking", self.job_tracking.as_deref()),
3730            ("idempotency", self.idempotency.as_deref()),
3731            (
3732                "experiment_assignments",
3733                self.experiment_assignments.as_deref(),
3734            ),
3735            ("webhook_replay", self.webhook_replay.as_deref()),
3736            ("sessions", self.sessions.as_deref()),
3737            ("audit_archives", self.audit_archives.as_deref()),
3738            ("custom_domains", self.custom_domains.as_deref()),
3739        ]
3740    }
3741
3742    /// `true` when at least one dataset declares a retention window.
3743    ///
3744    /// When this is `false` no sweep task is registered and no framework
3745    /// behavior changes — AC #1's "leaving a dataset unset preserves today's
3746    /// behavior exactly", enforced structurally rather than by each sweeper
3747    /// remembering to check.
3748    #[must_use]
3749    pub fn any_window_configured(&self) -> bool {
3750        self.windows().iter().any(|(_, window)| window.is_some())
3751    }
3752
3753    /// The configured window for one dataset key, already parsed.
3754    ///
3755    /// Returns `None` both when the dataset is unset and when its value does
3756    /// not parse — [`Self::validate`] rejects the latter at boot, so a
3757    /// running app never reaches this with an unparseable value.
3758    #[must_use]
3759    pub fn window(&self, key: &str) -> Option<std::time::Duration> {
3760        self.windows()
3761            .into_iter()
3762            .find(|(name, _)| *name == key)
3763            .and_then(|(_, window)| window)
3764            .and_then(crate::task::parse_duration)
3765    }
3766
3767    /// How often the sweep runs, already parsed. Falls back to one hour when
3768    /// unparseable ([`Self::validate`] rejects that at boot).
3769    #[must_use]
3770    pub fn sweep_interval_duration(&self) -> std::time::Duration {
3771        crate::task::parse_duration(&self.sweep_interval)
3772            .unwrap_or(std::time::Duration::from_secs(3_600))
3773    }
3774
3775    /// Validate every declared window.
3776    ///
3777    /// # Errors
3778    ///
3779    /// Returns [`ConfigError::Validation`] when a window (or the sweep
3780    /// interval) is not a valid duration string, or resolves to zero — a zero
3781    /// window would purge a dataset the instant it was written, which is
3782    /// never what an operator means and is not something to guess at.
3783    pub fn validate(&self) -> Result<(), ConfigError> {
3784        for (key, window) in self.windows() {
3785            let Some(raw) = window else { continue };
3786            // One message, not two: `parse_duration` already returns `None`
3787            // for a syntactically valid but zero total ("0s"), so a separate
3788            // "resolves to zero" branch would be unreachable. State what is
3789            // required — valid *and* non-zero — and why zero is refused.
3790            let Some(parsed) = crate::task::parse_duration(raw) else {
3791                return Err(ConfigError::Validation(format!(
3792                    "retention.{key} = {raw:?} is not a valid non-zero duration: use a \
3793                     string like \"90d\", \"12h\", or \"1h 30m\". A zero window would \
3794                     purge the dataset as soon as it is written; remove the key entirely \
3795                     to keep today's behavior (see docs/guide/data-retention.md)"
3796                )));
3797            };
3798            // The sweep binds its cutoff as `NOW() - make_interval(secs => $1)`
3799            // with a `u32`-representable value. Rejecting anything larger here
3800            // keeps the configured window and the window the database actually
3801            // applies identical — silently clamping would delete rows the
3802            // report claimed were still inside the policy. 136 years is far
3803            // past any real retention policy, so this only ever catches a typo.
3804            if parsed.as_secs() > u64::from(u32::MAX) {
3805                return Err(ConfigError::Validation(format!(
3806                    "retention.{key} = {raw:?} is longer than the maximum supported \
3807                     retention window ({} years). Remove the key entirely to keep the \
3808                     data forever, which is what a window that long means in practice \
3809                     (see docs/guide/data-retention.md)",
3810                    u64::from(u32::MAX) / (365 * 86_400)
3811                )));
3812            }
3813        }
3814
3815        // Only meaningful once something is actually swept, but validated
3816        // unconditionally so a typo is caught the first time it is written
3817        // rather than the first time a window is added next to it.
3818        if crate::task::parse_duration(&self.sweep_interval).is_none() {
3819            return Err(ConfigError::Validation(format!(
3820                "retention.sweep_interval = {:?} is not a valid non-zero duration: use a \
3821                 string like \"1h\" or \"30m\" (see docs/guide/data-retention.md)",
3822                self.sweep_interval
3823            )));
3824        }
3825        Ok(())
3826    }
3827}
3828
3829/// Scheduled task coordination runtime configuration.
3830#[derive(Debug, Clone, Deserialize)]
3831pub struct SchedulerConfig {
3832    /// Runtime backend selection.
3833    #[serde(default)]
3834    pub backend: SchedulerBackend,
3835    /// Lease duration used by distributed backends for run visibility and timeout guidance.
3836    #[serde(default = "default_scheduler_lease_ttl_secs")]
3837    pub lease_ttl_secs: u64,
3838    /// Stable replica identifier surfaced in actuator metadata.
3839    #[serde(default)]
3840    pub replica_id: Option<String>,
3841    /// Prefix included when deriving Postgres advisory lock keys.
3842    #[serde(default = "default_scheduler_key_prefix")]
3843    pub key_prefix: String,
3844}
3845
3846impl SchedulerConfig {
3847    /// Resolve a stable-ish replica identifier for actuator metadata and lock ownership.
3848    #[must_use]
3849    pub fn resolved_replica_id(&self) -> String {
3850        self.replica_id
3851            .as_ref()
3852            .filter(|id| !id.trim().is_empty())
3853            .cloned()
3854            .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
3855            .or_else(|| std::env::var("HOSTNAME").ok())
3856            .unwrap_or_else(|| format!("pid-{}", std::process::id()))
3857    }
3858
3859    /// Validate scheduler-specific config shape.
3860    ///
3861    /// # Errors
3862    ///
3863    /// Returns [`ConfigError::Validation`] when values are syntactically valid TOML
3864    /// but cannot be used by the runtime.
3865    pub fn validate(&self) -> Result<(), ConfigError> {
3866        if self.lease_ttl_secs == 0 {
3867            return Err(ConfigError::Validation(
3868                "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
3869            ));
3870        }
3871        if self.key_prefix.trim().is_empty() {
3872            return Err(ConfigError::Validation(
3873                "scheduler.key_prefix must not be empty".to_owned(),
3874            ));
3875        }
3876        Ok(())
3877    }
3878}
3879
3880impl Default for SchedulerConfig {
3881    fn default() -> Self {
3882        Self {
3883            backend: SchedulerBackend::default(),
3884            lease_ttl_secs: default_scheduler_lease_ttl_secs(),
3885            replica_id: None,
3886            key_prefix: default_scheduler_key_prefix(),
3887        }
3888    }
3889}
3890
3891const fn default_scheduler_lease_ttl_secs() -> u64 {
3892    300
3893}
3894
3895fn default_scheduler_key_prefix() -> String {
3896    "autumn:scheduler".to_owned()
3897}
3898
3899/// Storage backend selection for HTTP idempotency keys.
3900#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
3901#[serde(rename_all = "lowercase")]
3902#[non_exhaustive]
3903pub enum IdempotencyBackend {
3904    /// In-process store. One replica only.
3905    #[default]
3906    Memory,
3907    /// Redis store. Shared by replicas; the record is written after the handler.
3908    Redis,
3909    /// App-database store. A handler can commit the record with its mutation.
3910    Database,
3911}
3912
3913impl IdempotencyBackend {
3914    /// Parse an environment variable value for idempotency backend selection.
3915    #[must_use]
3916    pub fn from_env_value(value: &str) -> Option<Self> {
3917        match value.trim().to_ascii_lowercase().as_str() {
3918            "memory" | "mem" => Some(Self::Memory),
3919            "redis" => Some(Self::Redis),
3920            "database" | "db" => Some(Self::Database),
3921            _ => None,
3922        }
3923    }
3924}
3925
3926/// Redis connection settings for the idempotency backend.
3927#[derive(Debug, Clone, Deserialize)]
3928pub struct IdempotencyRedisConfig {
3929    /// Redis connection URL (e.g. `redis://localhost:6379`).
3930    pub url: Option<String>,
3931    /// Key prefix for all idempotency entries and locks stored in Redis.
3932    #[serde(default = "default_idempotency_redis_key_prefix")]
3933    pub key_prefix: String,
3934}
3935
3936impl Default for IdempotencyRedisConfig {
3937    fn default() -> Self {
3938        Self {
3939            url: None,
3940            key_prefix: default_idempotency_redis_key_prefix(),
3941        }
3942    }
3943}
3944
3945fn default_idempotency_redis_key_prefix() -> String {
3946    "autumn:idempotency".to_owned()
3947}
3948
3949/// HTTP idempotency-key middleware settings.
3950#[derive(Debug, Clone, Deserialize)]
3951pub struct IdempotencyConfig {
3952    /// Enable the idempotency-key middleware.
3953    ///
3954    /// When `true`, mutating requests that carry an `Idempotency-Key` header
3955    /// are deduplicated using the configured backend.
3956    ///
3957    /// `None` means the field was absent from the config file; the
3958    /// `AppBuilder::idempotent()` builder flag may still enable it.
3959    /// `Some(false)` is an explicit operator opt-out that overrides the builder.
3960    #[serde(default)]
3961    pub enabled: Option<bool>,
3962    /// Storage backend for idempotency records.
3963    #[serde(default)]
3964    pub backend: IdempotencyBackend,
3965    /// Time-to-live in seconds for stored idempotency records.
3966    #[serde(default = "default_idempotency_ttl_secs")]
3967    pub ttl_secs: u64,
3968    /// Maximum lifetime of an in-flight lock, in seconds. Default: 60.
3969    ///
3970    /// The lock is released when the handler finishes. This expiry applies
3971    /// only after a crash, a cancelled request, or a failed record write; it
3972    /// sets how long a retry gets `409`. Keep it longer than your slowest
3973    /// mutating request. It is separate from `ttl_secs`, the response
3974    /// retention.
3975    #[serde(default = "default_idempotency_in_flight_ttl_secs")]
3976    pub in_flight_ttl_secs: u64,
3977    /// Allow the in-memory backend in production environments.
3978    #[serde(default)]
3979    pub allow_memory_in_production: bool,
3980    /// Redis connection settings (used when `backend = "redis"`).
3981    #[serde(default)]
3982    pub redis: IdempotencyRedisConfig,
3983}
3984
3985impl Default for IdempotencyConfig {
3986    fn default() -> Self {
3987        Self {
3988            enabled: None,
3989            backend: IdempotencyBackend::default(),
3990            ttl_secs: default_idempotency_ttl_secs(),
3991            in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
3992            allow_memory_in_production: false,
3993            redis: IdempotencyRedisConfig::default(),
3994        }
3995    }
3996}
3997
3998const fn default_idempotency_ttl_secs() -> u64 {
3999    86_400
4000}
4001
4002const fn default_idempotency_in_flight_ttl_secs() -> u64 {
4003    crate::idempotency::DEFAULT_IN_FLIGHT_TTL.as_secs()
4004}
4005
4006/// `OpenAPI` spec runtime exposure settings.
4007///
4008/// Populated from the `[openapi]` block in `autumn.toml`. When
4009/// `AppBuilder::openapi(...)` is called and `enabled = true`, the framework
4010/// mounts the spec at `path`. Set `enabled = false` in a production profile
4011/// to prevent exposing the spec publicly.
4012///
4013/// # `autumn.toml` example
4014///
4015/// ```toml
4016/// [openapi]
4017/// enabled = false   # disable in prod
4018/// path = "/openapi.json"
4019/// ```
4020#[derive(Debug, Clone, Deserialize)]
4021pub struct OpenApiRuntimeConfig {
4022    /// Whether the `OpenAPI` spec endpoint is served.
4023    ///
4024    /// Defaults to `true` so new projects get the spec immediately.
4025    /// Set to `false` in production profiles to suppress the endpoint.
4026    #[serde(default = "default_openapi_enabled")]
4027    pub enabled: bool,
4028    /// URL path at which `openapi.json` is served.
4029    ///
4030    /// Defaults to `/openapi.json`.
4031    #[serde(default = "default_openapi_path")]
4032    pub path: String,
4033}
4034
4035impl Default for OpenApiRuntimeConfig {
4036    fn default() -> Self {
4037        Self {
4038            enabled: default_openapi_enabled(),
4039            path: default_openapi_path(),
4040        }
4041    }
4042}
4043
4044const fn default_openapi_enabled() -> bool {
4045    true
4046}
4047
4048fn default_openapi_path() -> String {
4049    "/openapi.json".to_owned()
4050}
4051
4052/// Transactional outbox relay configuration (issue #3062).
4053///
4054/// See `docs/guide/outbox.md`. The relay runs only when `enabled` is `true`
4055/// and the app has a database.
4056#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
4057#[serde(default)]
4058pub struct OutboxConfig {
4059    /// Start the relay, create the tables at boot, and send `deliver_later`
4060    /// mail through the outbox. Default `false`.
4061    pub enabled: bool,
4062    /// Wait between relay polls when no message is ready. Default `500`.
4063    pub poll_interval_ms: u64,
4064    /// Messages one relay claims per poll. Default `100`.
4065    pub batch_size: usize,
4066    /// Attempts before a message goes to the dead letters. Default `10`.
4067    pub max_attempts: u32,
4068    /// First retry delay. Each retry doubles it, with jitter. Default `1000`.
4069    pub initial_backoff_ms: u64,
4070    /// Upper limit of the retry delay. Default `300000` (5 minutes).
4071    pub max_backoff_ms: u64,
4072    /// How long a claim stays valid. After a crash, the message is sent again
4073    /// when the claim expires. Default `60000`.
4074    pub lease_ms: u64,
4075    /// Age after which the relay deletes sent messages and inbox entries.
4076    /// Default `604800000` (7 days).
4077    pub retention_ms: u64,
4078}
4079
4080impl OutboxConfig {
4081    /// Check the values the relay needs.
4082    ///
4083    /// # Errors
4084    ///
4085    /// Returns [`ConfigError::Validation`] when `batch_size`, `max_attempts`
4086    /// or `lease_ms` is zero, or `max_backoff_ms` is smaller than
4087    /// `initial_backoff_ms`. With a zero lease, a claim ends at once and the
4088    /// relay sends nothing.
4089    pub fn validate(&self) -> Result<(), ConfigError> {
4090        for (key, value) in [
4091            (
4092                "batch_size",
4093                u64::try_from(self.batch_size).unwrap_or(u64::MAX),
4094            ),
4095            ("max_attempts", u64::from(self.max_attempts)),
4096            ("lease_ms", self.lease_ms),
4097        ] {
4098            if value == 0 {
4099                return Err(ConfigError::Validation(format!(
4100                    "outbox.{key} must be greater than zero"
4101                )));
4102            }
4103        }
4104        if self.max_backoff_ms < self.initial_backoff_ms {
4105            return Err(ConfigError::Validation(
4106                "outbox.max_backoff_ms must not be smaller than outbox.initial_backoff_ms"
4107                    .to_owned(),
4108            ));
4109        }
4110        Ok(())
4111    }
4112}
4113
4114impl Default for OutboxConfig {
4115    fn default() -> Self {
4116        Self {
4117            enabled: false,
4118            poll_interval_ms: 500,
4119            batch_size: 100,
4120            max_attempts: 10,
4121            initial_backoff_ms: 1_000,
4122            max_backoff_ms: 300_000,
4123            lease_ms: 60_000,
4124            retention_ms: 604_800_000,
4125        }
4126    }
4127}
4128
4129/// Background job runtime configuration.
4130#[derive(Debug, Clone, Deserialize)]
4131pub struct JobConfig {
4132    /// Runtime backend selection.
4133    ///
4134    /// - `local` (default): in-process Tokio queue
4135    /// - `postgres`: Postgres-backed durable queue (requires `db` feature)
4136    /// - `redis`: Redis-backed durable queue (requires `redis` feature)
4137    /// - `sqlite`: durable queue in the app's own `SQLite` file (requires the
4138    ///   `sqlite` feature)
4139    #[serde(default = "default_job_backend")]
4140    pub backend: String,
4141    /// Number of concurrent worker loops to spawn.
4142    #[serde(default = "default_job_workers")]
4143    pub workers: usize,
4144    /// Default max attempts when `#[job(max_attempts = ...)]` is not set.
4145    #[serde(default = "default_job_max_attempts")]
4146    pub max_attempts: u32,
4147    /// Default initial retry backoff in milliseconds.
4148    #[serde(default = "default_job_backoff_ms")]
4149    pub initial_backoff_ms: u64,
4150    /// Cap on the retry backoff in milliseconds, for every backend.
4151    /// Default: 3 600 000 (1 hour). See [`crate::backoff`].
4152    #[serde(default = "default_job_max_backoff_ms")]
4153    pub max_backoff_ms: u64,
4154    /// Maximum time in milliseconds for one run of a job that has no
4155    /// `#[job(timeout)]` (issue #3051). A slower run fails and retries. `0`
4156    /// (the default) sets no limit.
4157    #[serde(default)]
4158    pub default_timeout_ms: u64,
4159    /// Ordered/weighted list of queues workers drain, highest priority first.
4160    ///
4161    /// Unset = a single `default` queue (today's behavior). A TOML array such as
4162    /// `queues = ["critical", "default", "low"]` is **strict priority**; a table
4163    /// such as `[jobs.queues] critical = 4` / `default = 1` is **weighted**
4164    /// (probabilistic fair draining that never starves lower queues).
4165    #[serde(default)]
4166    pub queues: JobQueuesConfig,
4167    /// Queues this process is pinned to. Empty (default) = claim every
4168    /// configured/declared queue (today's behavior). When non-empty, this
4169    /// worker process only ever claims jobs from queues in this set — on every
4170    /// backend — so a worker tier can be dedicated to a subset of queues
4171    /// (issue #1623, AC3). Names outside the configured/declared topology are
4172    /// ignored. Set from `AUTUMN_JOBS__PIN` (comma-separated) too.
4173    #[serde(default)]
4174    pub pin: Vec<String>,
4175    /// Declared worker-fleet topology, read by `autumn doctor` to prove
4176    /// topology-wide queue coverage (issue #1623, AC6). Purely declarative: the
4177    /// running process never acts on it — it describes the *other* tiers, which
4178    /// a single process cannot observe.
4179    #[serde(default)]
4180    pub fleet: JobFleetConfig,
4181    /// Redis backend options.
4182    #[serde(default)]
4183    pub redis: JobRedisConfig,
4184    /// Postgres backend options.
4185    #[serde(default)]
4186    pub postgres: JobPostgresConfig,
4187    /// `SQLite` backend options.
4188    #[serde(default)]
4189    pub sqlite: JobSqliteConfig,
4190    /// Tracked-job progress/result store options (`enqueue_tracked`, the
4191    /// built-in `GET /_autumn/jobs/{token}` status route).
4192    #[serde(default)]
4193    pub tracking: JobTrackingConfig,
4194    /// Per-tenant worker slots and shuffle-shard lanes (issue #3072).
4195    #[serde(default)]
4196    pub tenants: JobTenantsConfig,
4197}
4198
4199/// Tenant isolation for job workers (issue #3072).
4200///
4201/// Applies to the `local` backend. A job's tenant is the tenant of the
4202/// request that enqueued it. Jobs without a tenant are not limited.
4203#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
4204pub struct JobTenantsConfig {
4205    /// The most jobs of one tenant that run at the same time. `0` (the
4206    /// default) sets no limit.
4207    #[serde(default)]
4208    pub max_concurrent: usize,
4209    /// The number of shuffle-shard lanes. Worker `i` serves lane
4210    /// `i % lanes`. `0` (the default) turns lanes off. The runtime uses at
4211    /// most `jobs.workers` lanes.
4212    #[serde(default)]
4213    pub lanes: u16,
4214    /// The number of lanes that serve each tenant. `0` means 1.
4215    #[serde(default)]
4216    pub lanes_per_tenant: u16,
4217}
4218
4219impl Default for JobConfig {
4220    fn default() -> Self {
4221        Self {
4222            backend: default_job_backend(),
4223            workers: default_job_workers(),
4224            max_attempts: default_job_max_attempts(),
4225            initial_backoff_ms: default_job_backoff_ms(),
4226            max_backoff_ms: default_job_max_backoff_ms(),
4227            default_timeout_ms: 0,
4228            queues: JobQueuesConfig::default(),
4229            pin: Vec::new(),
4230            fleet: JobFleetConfig::default(),
4231            redis: JobRedisConfig::default(),
4232            postgres: JobPostgresConfig::default(),
4233            sqlite: JobSqliteConfig::default(),
4234            tracking: JobTrackingConfig::default(),
4235            tenants: JobTenantsConfig::default(),
4236        }
4237    }
4238}
4239
4240/// A single named queue and its draining weight, plus optional per-queue
4241/// worker-pool controls.
4242#[derive(Debug, Clone, PartialEq, Eq)]
4243pub struct JobQueue {
4244    /// Queue name, as declared by `#[job(queue = "...")]`.
4245    pub name: String,
4246    /// Relative draining weight (used only for weighted draining; `1` for the
4247    /// strict-priority list form).
4248    pub weight: u32,
4249    /// Optional hard cap on how many of the process's worker slots this queue
4250    /// may occupy at once. `None` = uncapped (may use the whole shared pool).
4251    /// Lets a bulk queue never exceed its configured share (issue #1623, AC2).
4252    pub concurrency: Option<usize>,
4253    /// Optional number of worker slots dedicated to this queue that no other
4254    /// queue may consume. `None`/`0` = no reservation. Guarantees a queue keeps
4255    /// making progress even while another queue floods (issue #1623, AC1).
4256    pub reserved: Option<usize>,
4257}
4258
4259impl JobQueue {
4260    /// A weight-only queue (no per-queue caps or reservations).
4261    #[must_use]
4262    pub fn new(name: impl Into<String>, weight: u32) -> Self {
4263        Self {
4264            name: name.into(),
4265            weight,
4266            concurrency: None,
4267            reserved: None,
4268        }
4269    }
4270}
4271
4272/// Worker queue drain configuration parsed from `[jobs] queues`.
4273///
4274/// Accepts **either** a TOML array (strict priority, in order) **or** a TOML
4275/// table of `name = weight` (weighted, fair). Empty or unset falls back to a
4276/// single `default` queue so an app that doesn't opt in behaves exactly as today.
4277#[derive(Debug, Clone, PartialEq, Eq)]
4278pub struct JobQueuesConfig {
4279    /// Configured queues, highest priority first.
4280    pub queues: Vec<JobQueue>,
4281    /// `true` for the ordered-list form (strict priority); `false` for the
4282    /// weighted-table form (deficit weighted round-robin).
4283    pub strict: bool,
4284}
4285
4286impl Default for JobQueuesConfig {
4287    fn default() -> Self {
4288        Self::single_default()
4289    }
4290}
4291
4292impl JobQueuesConfig {
4293    /// The zero-config default: one strict `default` queue.
4294    #[must_use]
4295    pub fn single_default() -> Self {
4296        Self {
4297            queues: vec![JobQueue::new("default", 1)],
4298            strict: true,
4299        }
4300    }
4301
4302    /// Build a strict-priority schedule from an ordered list of queue names.
4303    #[must_use]
4304    pub fn strict_list<I, S>(names: I) -> Self
4305    where
4306        I: IntoIterator<Item = S>,
4307        S: Into<String>,
4308    {
4309        let queues: Vec<JobQueue> = names
4310            .into_iter()
4311            .map(|name| JobQueue::new(name, 1))
4312            .collect();
4313        if queues.is_empty() {
4314            Self::single_default()
4315        } else {
4316            Self {
4317                queues,
4318                strict: true,
4319            }
4320        }
4321    }
4322
4323    /// Build a weighted schedule from `(name, weight)` pairs. Weights are
4324    /// clamped to a minimum of `1` so every configured queue makes progress.
4325    #[must_use]
4326    pub fn weighted<I, S>(entries: I) -> Self
4327    where
4328        I: IntoIterator<Item = (S, u32)>,
4329        S: Into<String>,
4330    {
4331        let queues: Vec<JobQueue> = entries
4332            .into_iter()
4333            .map(|(name, weight)| JobQueue::new(name, weight.max(1)))
4334            .collect();
4335        if queues.is_empty() {
4336            Self::single_default()
4337        } else {
4338            Self {
4339                queues,
4340                strict: false,
4341            }
4342        }
4343    }
4344
4345    /// Build a weighted schedule from fully-specified [`JobQueue`] entries
4346    /// (weight plus optional per-queue `concurrency` cap and `reserved` slots).
4347    /// Weights are clamped to a minimum of `1`. Empty input falls back to the
4348    /// zero-config single `default` queue.
4349    #[must_use]
4350    pub fn weighted_specs(queues: Vec<JobQueue>) -> Self {
4351        if queues.is_empty() {
4352            Self::single_default()
4353        } else {
4354            Self {
4355                queues: queues
4356                    .into_iter()
4357                    .map(|mut q| {
4358                        q.weight = q.weight.max(1);
4359                        q
4360                    })
4361                    .collect(),
4362                strict: false,
4363            }
4364        }
4365    }
4366}
4367
4368/// One value in the `[jobs.queues]` weight table: either a bare integer weight
4369/// (`critical = 4`) or a table with per-queue pool controls
4370/// (`critical = { weight = 4, concurrency = 8, reserved = 2 }`).
4371#[derive(Debug, Clone)]
4372enum JobQueueValue {
4373    Weight(u32),
4374    Spec {
4375        weight: Option<u32>,
4376        concurrency: Option<usize>,
4377        reserved: Option<usize>,
4378    },
4379}
4380
4381impl<'de> serde::Deserialize<'de> for JobQueueValue {
4382    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
4383        use serde::de::{MapAccess, Visitor};
4384        use std::fmt;
4385
4386        struct ValueVisitor;
4387
4388        impl<'de> Visitor<'de> for ValueVisitor {
4389            type Value = JobQueueValue;
4390
4391            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
4392                f.write_str(
4393                    "a queue weight (e.g. critical = 4) or a queue table \
4394                     (e.g. critical = { weight = 4, concurrency = 8, reserved = 2 })",
4395                )
4396            }
4397
4398            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
4399                Ok(JobQueueValue::Weight(
4400                    u32::try_from(v).map_err(|_| E::custom("queue weight is too large"))?,
4401                ))
4402            }
4403
4404            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
4405                if v < 0 {
4406                    return Err(E::custom("queue weight must not be negative"));
4407                }
4408                self.visit_u64(u64::try_from(v).unwrap_or(0))
4409            }
4410
4411            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
4412                let mut weight = None;
4413                let mut concurrency = None;
4414                let mut reserved = None;
4415                while let Some(key) = map.next_key::<String>()? {
4416                    match key.as_str() {
4417                        "weight" => weight = Some(map.next_value::<u32>()?),
4418                        "concurrency" => concurrency = Some(map.next_value::<usize>()?),
4419                        "reserved" => reserved = Some(map.next_value::<usize>()?),
4420                        other => {
4421                            return Err(serde::de::Error::custom(format!(
4422                                "unknown queue setting '{other}' (expected weight, concurrency, \
4423                                 or reserved)"
4424                            )));
4425                        }
4426                    }
4427                }
4428                Ok(JobQueueValue::Spec {
4429                    weight,
4430                    concurrency,
4431                    reserved,
4432                })
4433            }
4434        }
4435
4436        d.deserialize_any(ValueVisitor)
4437    }
4438}
4439
4440impl<'de> serde::Deserialize<'de> for JobQueuesConfig {
4441    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
4442        use serde::de::{MapAccess, SeqAccess, Visitor};
4443        use std::fmt;
4444
4445        struct JobQueuesVisitor;
4446
4447        impl<'de> Visitor<'de> for JobQueuesVisitor {
4448            type Value = JobQueuesConfig;
4449
4450            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
4451                f.write_str(
4452                    "an ordered list of queue names (e.g. queues = [\"critical\", \"default\"]) \
4453                     or a weight table (e.g. [jobs.queues] critical = 4, default = 1)",
4454                )
4455            }
4456
4457            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
4458                let mut names = Vec::new();
4459                let mut seen = std::collections::HashSet::new();
4460                while let Some(name) = seq.next_element::<String>()? {
4461                    if !seen.insert(name.clone()) {
4462                        return Err(serde::de::Error::custom(format!(
4463                            "duplicate queue name '{name}' in queues list"
4464                        )));
4465                    }
4466                    names.push(name);
4467                }
4468                Ok(JobQueuesConfig::strict_list(names))
4469            }
4470
4471            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
4472                let mut queues: Vec<JobQueue> = Vec::new();
4473                while let Some((k, value)) = map.next_entry::<String, JobQueueValue>()? {
4474                    let (weight, concurrency, reserved) = match value {
4475                        JobQueueValue::Weight(w) => (w, None, None),
4476                        JobQueueValue::Spec {
4477                            weight,
4478                            concurrency,
4479                            reserved,
4480                        } => (weight.unwrap_or(1), concurrency, reserved),
4481                    };
4482                    if weight == 0 {
4483                        return Err(serde::de::Error::custom(format!(
4484                            "queue '{k}' weight must be at least 1 (got 0); \
4485                             to disable a queue remove it from the list"
4486                        )));
4487                    }
4488                    queues.push(JobQueue {
4489                        name: k,
4490                        weight,
4491                        concurrency,
4492                        reserved,
4493                    });
4494                }
4495                Ok(JobQueuesConfig::weighted_specs(queues))
4496            }
4497        }
4498
4499        d.deserialize_any(JobQueuesVisitor)
4500    }
4501}
4502
4503/// Declared worker-fleet topology (`[jobs.fleet]` in `autumn.toml`).
4504///
4505/// A single process can see its own `jobs.pin` but not its siblings', so it can
4506/// never tell a valid multi-tier split (one tier drains `critical`, another
4507/// drains `bulk`) from a real coverage gap. Declaring every tier's pin here is
4508/// what lets `autumn doctor --strict` *prove* that some queue is drained by no
4509/// tier anywhere and hard-fail on it (issue #1623, AC6) instead of only
4510/// reporting what this one process claims.
4511///
4512/// # Example `autumn.toml`
4513///
4514/// ```toml
4515/// [jobs.fleet]
4516/// # One entry per worker tier, each holding that tier's `jobs.pin`. Must list
4517/// # every tier actually running: doctor can only reason about declared tiers,
4518/// # so omitting one reports a coverage gap that does not exist.
4519/// # An empty entry is an *unpinned* tier that drains every queue.
4520/// tiers = [["critical"], ["bulk", "default", "thumbnails"]]
4521/// # Optional: where the compiled `#[job(queue = "…")]` set comes from, so the
4522/// # check also covers queues declared in code but absent from `[jobs.queues]`.
4523/// # `manifest` (emitted by `autumn jobs manifest <path>`) wins when both are
4524/// # set. Every queue they name must be covered by some tier above.
4525/// manifest = "target/jobs-manifest.toml"
4526/// declared_queues = ["thumbnails"]
4527/// ```
4528///
4529/// The framework itself never reads this at runtime — it is operator-declared
4530/// input for the `doctor` check, and an app that declares nothing keeps today's
4531/// behavior exactly (the check stays informational-only).
4532#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
4533pub struct JobFleetConfig {
4534    /// One entry per worker tier, each entry being that tier's `jobs.pin`. An
4535    /// **empty** entry is an unpinned tier that drains every queue, which makes
4536    /// topology-wide coverage total. Empty/unset declares no topology.
4537    #[serde(default)]
4538    pub tiers: Vec<Vec<String>>,
4539
4540    /// Path to a jobs manifest (a TOML file with a `queues = [...]` array) the
4541    /// app emits, naming the queues `#[job(queue = "…")]` declares. Lets the
4542    /// coverage check see queues that exist in code but not in `[jobs.queues]`.
4543    /// Takes precedence over [`Self::declared_queues`].
4544    #[serde(default)]
4545    pub manifest: Option<String>,
4546
4547    /// Inline list of `#[job(queue = "…")]`-declared queue names, for operators
4548    /// who don't emit a manifest. Only *adds* to the set of queues that must be
4549    /// covered, so an incomplete list can never manufacture a false failure.
4550    #[serde(default)]
4551    pub declared_queues: Vec<String>,
4552}
4553
4554/// Redis backend configuration options for the job runner.
4555#[derive(Debug, Clone, Deserialize)]
4556pub struct JobRedisConfig {
4557    /// Redis URL used when `jobs.backend = "redis"`.
4558    #[serde(default)]
4559    pub url: Option<String>,
4560    /// Key prefix for all queue keys.
4561    #[serde(default = "default_jobs_redis_prefix")]
4562    pub key_prefix: String,
4563    /// Duration before an in-flight job claim is considered stale.
4564    #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
4565    pub visibility_timeout_ms: u64,
4566    /// Maximum number of entries in the dead-letter list. The worker removes
4567    /// the oldest entries above this limit, logs a warning and increments
4568    /// `autumn_jobs_dead_letter_trimmed_total`. `0` keeps all entries.
4569    /// Default: 10 000.
4570    #[serde(default = "default_jobs_redis_dead_letter_limit")]
4571    pub dead_letter_limit: usize,
4572}
4573
4574impl Default for JobRedisConfig {
4575    fn default() -> Self {
4576        Self {
4577            url: None,
4578            key_prefix: default_jobs_redis_prefix(),
4579            visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
4580            dead_letter_limit: default_jobs_redis_dead_letter_limit(),
4581        }
4582    }
4583}
4584
4585/// Postgres backend configuration options for the job runner.
4586#[derive(Debug, Clone, Deserialize)]
4587pub struct JobPostgresConfig {
4588    /// Duration before an in-flight job claim is considered stale and recovered.
4589    ///
4590    /// Workers that crash mid-job have their claim reclaimed by another worker
4591    /// within this bound. Default: 30 seconds.
4592    #[serde(default = "default_jobs_pg_visibility_timeout_ms")]
4593    pub visibility_timeout_ms: u64,
4594    /// Shard-local jobs (issue #3072). Make an `autumn_jobs` table on each
4595    /// shard. Run workers for each shard. Then `enqueue_in_tx` on a shard
4596    /// connection commits or rolls back with the shard's data. Default:
4597    /// `false`.
4598    #[serde(default)]
4599    pub shard_local: bool,
4600}
4601
4602impl Default for JobPostgresConfig {
4603    fn default() -> Self {
4604        Self {
4605            visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
4606            shard_local: false,
4607        }
4608    }
4609}
4610
4611/// `SQLite` backend configuration options for the job runner (issue #1907).
4612#[derive(Debug, Clone, Deserialize)]
4613pub struct JobSqliteConfig {
4614    /// Duration before an in-flight job claim is considered stale and recovered.
4615    ///
4616    /// A worker that dies mid-job has its claim reclaimed within this bound.
4617    /// Default: 30 seconds.
4618    #[serde(default = "default_jobs_sqlite_visibility_timeout_ms")]
4619    pub visibility_timeout_ms: u64,
4620    /// How long an idle worker waits before it polls the queue table again.
4621    ///
4622    /// `SQLite` has no `LISTEN`/`NOTIFY`, so a worker polls. An enqueue in the
4623    /// same process wakes a worker directly, so this bound sets how fast a
4624    /// worker sees work another process enqueued. Default: 250ms.
4625    #[serde(default = "default_jobs_sqlite_poll_interval_ms")]
4626    pub poll_interval_ms: u64,
4627}
4628
4629impl Default for JobSqliteConfig {
4630    fn default() -> Self {
4631        Self {
4632            visibility_timeout_ms: default_jobs_sqlite_visibility_timeout_ms(),
4633            poll_interval_ms: default_jobs_sqlite_poll_interval_ms(),
4634        }
4635    }
4636}
4637
4638const fn default_jobs_sqlite_visibility_timeout_ms() -> u64 {
4639    30_000
4640}
4641
4642const fn default_jobs_sqlite_poll_interval_ms() -> u64 {
4643    250
4644}
4645
4646/// Tracked-job progress/result store configuration.
4647#[derive(Debug, Clone, Deserialize)]
4648pub struct JobTrackingConfig {
4649    /// How long a tracked job's progress/result record is retained after its
4650    /// last write, in seconds. Default: 24 hours.
4651    #[serde(default = "default_jobs_tracking_ttl_secs")]
4652    pub ttl_secs: u64,
4653    /// Whether the built-in `GET /_autumn/jobs/{token}` status route is
4654    /// mounted. Default: `true`.
4655    #[serde(default = "default_jobs_tracking_route_enabled")]
4656    pub route_enabled: bool,
4657}
4658
4659impl Default for JobTrackingConfig {
4660    fn default() -> Self {
4661        Self {
4662            ttl_secs: default_jobs_tracking_ttl_secs(),
4663            route_enabled: default_jobs_tracking_route_enabled(),
4664        }
4665    }
4666}
4667
4668const fn default_jobs_tracking_ttl_secs() -> u64 {
4669    86_400
4670}
4671
4672const fn default_jobs_tracking_route_enabled() -> bool {
4673    true
4674}
4675
4676const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
4677    30_000
4678}
4679
4680fn default_job_backend() -> String {
4681    "local".to_owned()
4682}
4683
4684const fn default_job_workers() -> usize {
4685    1
4686}
4687
4688const fn default_job_max_attempts() -> u32 {
4689    5
4690}
4691
4692const fn default_job_backoff_ms() -> u64 {
4693    250
4694}
4695
4696const fn default_job_max_backoff_ms() -> u64 {
4697    crate::backoff::DEFAULT_JOB_MAX_BACKOFF_MS
4698}
4699
4700fn default_jobs_redis_prefix() -> String {
4701    "autumn:jobs".to_owned()
4702}
4703
4704const fn default_jobs_redis_dead_letter_limit() -> usize {
4705    10_000
4706}
4707
4708const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
4709    30_000
4710}
4711
4712/// Parent config paths whose child keys were already covered by strict
4713/// validation BEFORE the #1890 schema-walk fix. Captured verbatim from
4714/// `get_schema_keys()` on the pre-fix code (the walk aborted at
4715/// `database.statement_timeout`, so only these parents were reachable). Used by
4716/// the warn-first rollout: an unknown key whose PARENT is in this set hard-fails
4717/// under `strict_config` exactly as before; every key the #1890 fix newly
4718/// reveals is warned about instead (until `strict_config_enforce_all` promotes
4719/// it). Transitional — removed when enforcement becomes the default.
4720const PRE_1890_STRICT_PARENTS: &[&str] = &[
4721    "",
4722    "database",
4723    "deploy",
4724    "server",
4725    "server.timeouts",
4726    "server.tls",
4727    "server.tls.acme",
4728];
4729
4730/// Whether an unknown-key error whose (profile-stripped, segment-derived) schema
4731/// parent is `schema_parent` was already hard-failing before #1890. Malformed
4732/// top-level profile entries surface with parent `"profile"` (always fatal
4733/// structural errors); everything else keys off the pre-#1890 parent set.
4734fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
4735    schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
4736}
4737
4738/// Policy for how the strict unknown-key check treats a genuinely-unknown
4739/// TOP-LEVEL config root — an unknown key whose schema parent is the document
4740/// root `""` (e.g. a plugin-owned `[media]` section that no core-schema key
4741/// covers).
4742///
4743/// App boot uses [`Strict`](UnknownRootPolicy::Strict): an unknown top-level
4744/// root is a hard error, exactly as before. Tooling that structurally cannot
4745/// know the application's plugin set — the deploy CLI, which has no
4746/// `AppBuilder`, no plugin list, and no plugin-crate dependency — uses
4747/// [`LenientWarn`](UnknownRootPolicy::LenientWarn): unknown top-level roots are
4748/// accepted as opaque with a single doctor-style warning, because app boot
4749/// (which DOES know the plugin set via the `config_section` seam / #2061 /
4750/// #1974) remains the authoritative strict gate for plugin-owned roots (#2063).
4751///
4752/// The leniency is scoped to top-level roots ONLY: unknown keys INSIDE a known
4753/// section (schema parent != `""`, e.g. a `[database] primry_url` typo) keep
4754/// their normal (hard) classification under both policies, and malformed TOML
4755/// still fails everywhere.
4756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4757pub(crate) enum UnknownRootPolicy {
4758    /// Unknown top-level roots hard-fail (the app-boot path).
4759    Strict,
4760    /// Unknown top-level roots are accepted as opaque with one warning (the
4761    /// deploy-CLI config-load path).
4762    LenientWarn,
4763}
4764
4765/// Child schema keys for config sections whose `Deserialize` is OPAQUE to the
4766/// schema walker and must be declared by hand.
4767///
4768/// `#[serde(untagged)]` "scalar shorthand OR table" enums (e.g. `TimeZoneConfig`:
4769/// `time_zone = "UTC"` or `[time_zone] identifier = ...`) deserialize by first
4770/// buffering into serde's `Content` and then matching variants against that
4771/// buffer — so the table variant's fields are read from the buffer, never from
4772/// `SchemaDeserializer`. The walker therefore cannot see them, and the section
4773/// would otherwise be a childless leaf that strict validation skips (accepting
4774/// typos even under `strict_config_enforce_all`). Register such sections here so
4775/// `validate_toml` descends into them.
4776///
4777/// KEEP IN SYNC with the corresponding type's table fields (serialized names).
4778/// The guard test `manual_schema_sections_are_registered` pins the behavior.
4779const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
4780    // `crate::time_zone::TimeZoneConfig` — untagged Scalar|Table.
4781    ("time_zone", &["identifier", "sources"]),
4782];
4783
4784impl AutumnConfig {
4785    /// Validate the `[push]` block, as `AppBuilder::run` does before binding.
4786    ///
4787    /// Factored out of the boot path so the rule it enforces — a VAPID key
4788    /// that is present but unusable is a hard failure, never a quiet fallback
4789    /// to "push disabled" — is reachable from a test. `run` calls exactly this
4790    /// and exits on `Err`.
4791    ///
4792    /// # Errors
4793    ///
4794    /// See [`crate::push::PushConfig::load_vapid_key`].
4795    pub fn validate_push(&self) -> Result<(), crate::push::PushError> {
4796        self.push.load_vapid_key().map(|_| ())
4797    }
4798
4799    /// The load-shedding ceiling the profile supplies when
4800    /// `server.max_concurrent_requests` is unset and no capacity contract
4801    /// applies (#3057).
4802    ///
4803    /// `prod` returns the primary pool size times
4804    /// [`PROD_REQUESTS_PER_POOL_CONNECTION`], and at least
4805    /// [`PROD_MIN_ADMISSION_LIMIT`]. Other profiles return `None` (no
4806    /// ceiling). Set `server.max_concurrent_requests = 0` to turn shedding off.
4807    #[must_use]
4808    pub fn profile_admission_default(&self) -> Option<usize> {
4809        matches!(self.profile.as_deref(), Some("prod" | "production")).then(|| {
4810            self.database
4811                .effective_primary_pool_size()
4812                .saturating_mul(PROD_REQUESTS_PER_POOL_CONNECTION)
4813                .max(PROD_MIN_ADMISSION_LIMIT)
4814        })
4815    }
4816}
4817
4818impl AutumnConfig {
4819    /// Recursively extracts all valid configuration schema keys and nested fields.
4820    #[must_use]
4821    #[allow(clippy::significant_drop_tightening)]
4822    pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
4823        // Adaptive multi-pass schema walk. `deserialize_any` probes with a scalar
4824        // by default; any path whose visitor rejects that probe (a seq/map-only
4825        // type such as `JobQueuesConfig` at `jobs.queues`) is escalated to a
4826        // map- then seq-probe on the next pass, so the walk stops aborting there
4827        // and enumerates every later section (#1890). Converges in two passes for
4828        // the current config; the loop is bounded and monotonic (each escalated
4829        // path only advances Str→Map→Seq), so it always terminates.
4830        const MAX_PASSES: usize = 8;
4831        let de = SchemaDeserializer::new();
4832        let mut prev_rejected: Vec<String> = Vec::new();
4833        for _ in 0..MAX_PASSES {
4834            de.rejected
4835                .lock()
4836                .unwrap_or_else(std::sync::PoisonError::into_inner)
4837                .clear();
4838            let _ = Self::deserialize(de.clone());
4839            let mut rejected: Vec<String> = std::mem::take(
4840                &mut de
4841                    .rejected
4842                    .lock()
4843                    .unwrap_or_else(std::sync::PoisonError::into_inner),
4844            );
4845            rejected.sort();
4846            rejected.dedup();
4847            if rejected.is_empty() {
4848                break;
4849            }
4850            let mut advanced = false;
4851            {
4852                let mut probes = de
4853                    .any_probe
4854                    .lock()
4855                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4856                for p in &rejected {
4857                    let cur = probes.get(p).copied().unwrap_or(AnyProbe::Str);
4858                    let next = match cur {
4859                        AnyProbe::Str => AnyProbe::Map,
4860                        AnyProbe::Map | AnyProbe::Seq => AnyProbe::Seq,
4861                    };
4862                    if next != cur {
4863                        advanced = true;
4864                    }
4865                    probes.insert(p.clone(), next);
4866                }
4867            }
4868            // No path could be escalated further and the rejected set is stable:
4869            // any remaining aborter accepts none of str/map/seq — stop (leaf it).
4870            if !advanced && rejected == prev_rejected {
4871                break;
4872            }
4873            prev_rejected = rejected;
4874        }
4875        // Register walker-opaque sections (untagged scalar-or-table types whose
4876        // table fields buffer through serde `Content` and are invisible to the
4877        // walk). See MANUAL_SCHEMA_SECTIONS.
4878        {
4879            let mut schema = de
4880                .schema
4881                .lock()
4882                .unwrap_or_else(std::sync::PoisonError::into_inner);
4883            for (section, keys) in MANUAL_SCHEMA_SECTIONS {
4884                let entry = schema.entry((*section).to_owned()).or_default();
4885                for k in *keys {
4886                    entry.insert((*k).to_owned());
4887                }
4888            }
4889        }
4890        de.into_schema()
4891    }
4892
4893    /// Returns a sorted set of all schema leaf key paths (e.g. `"server.port"`).
4894    ///
4895    /// Used by the schema-snapshot CI guard (`autumn/tests/schema_drift_guard.rs`)
4896    /// to detect when a config key disappears without a registered deprecation entry.
4897    /// Regenerate the snapshot with:
4898    /// ```text
4899    /// UPDATE_SCHEMA_SNAPSHOT=1 cargo test -p autumn-web schema_keys_snapshot_guard
4900    /// ```
4901    ///
4902    /// **Note:** Always run the guard under a consistent feature set (e.g. `--all-features`)
4903    /// in CI, since feature-gated fields only appear when their feature is enabled.
4904    #[must_use]
4905    pub fn schema_leaf_paths() -> std::collections::BTreeSet<String> {
4906        let schema = Self::get_schema_keys();
4907        let mut leaves = std::collections::BTreeSet::new();
4908        for (parent, fields) in &schema {
4909            for field in fields {
4910                let leaf = if parent.is_empty() {
4911                    field.clone()
4912                } else {
4913                    format!("{parent}.{field}")
4914                };
4915                leaves.insert(leaf);
4916            }
4917        }
4918        leaves
4919    }
4920
4921    /// Recursively validates TOML content against the derived schema.
4922    /// Returns a list of errors: (`dotted_path`, `option_suggestion`)
4923    #[must_use]
4924    pub fn validate_toml(
4925        content: &str,
4926        schema: &HashMap<String, HashSet<String>>,
4927    ) -> Vec<(String, Option<String>)> {
4928        Self::validate_toml_detailed(content, schema, &BTreeSet::new())
4929            .into_iter()
4930            .map(|(path, sug, _parent, _is_table, _is_top_level)| (path, sug))
4931            .collect()
4932    }
4933
4934    /// Like [`validate_toml`](Self::validate_toml), but also returns each error's
4935    /// profile-stripped schema parent path (computed from path SEGMENTS, so it is
4936    /// correct even for quoted dotted profile names like
4937    /// `[profile."prod.eu".server]`) AND whether the offending TOML value was
4938    /// itself a table (`is_table`), AND whether the offending key sat at the
4939    /// STRUCTURAL document top level (`is_top_level`, i.e. its parent path was
4940    /// empty at push time). Used by strict-config classification; `validate_toml`
4941    /// maps this down to `(path, suggestion)`.
4942    ///
4943    /// The `is_table` flag lets the deploy-CLI leniency (#2067) demote ONLY a
4944    /// true top-level TABLE root, mirroring the app-boot `config_section` seam
4945    /// (#2061) which exempts a registered plugin root only when `val.is_table()`.
4946    ///
4947    /// The `is_top_level` flag carries the same STRUCTURAL top-level signal the
4948    /// app-boot exemption uses (`path.is_empty()`), so deploy leniency can tell a
4949    /// genuine top-level root from a profile-prefixed one WITHOUT inspecting the
4950    /// rendered dotted `path` string — which is ambiguous, since a quoted top-level
4951    /// key like `["my.plugin"]` and a 2-level path both render `my.plugin`.
4952    ///
4953    /// `plugin_config_roots` lists top-level roots a plugin has declared via
4954    /// [`config_section`](crate::app::AppBuilder::config_section): each is
4955    /// treated as a known, opaque table — accepted at the root and never
4956    /// descended into. An empty set restores the pre-seam behavior.
4957    #[must_use]
4958    pub(crate) fn validate_toml_detailed(
4959        content: &str,
4960        schema: &HashMap<String, HashSet<String>>,
4961        plugin_config_roots: &BTreeSet<String>,
4962    ) -> Vec<(String, Option<String>, String, bool, bool)> {
4963        let Ok(table) = toml::from_str::<toml::Table>(content) else {
4964            return Vec::new();
4965        };
4966
4967        let mut errors = Vec::new();
4968        let mut path = Vec::new();
4969        Self::validate_toml_table(&table, &mut path, schema, plugin_config_roots, &mut errors);
4970        errors
4971    }
4972
4973    #[allow(clippy::too_many_lines)]
4974    fn validate_toml_table(
4975        table: &toml::Table,
4976        path: &mut Vec<String>,
4977        schema: &HashMap<String, HashSet<String>>,
4978        plugin_config_roots: &BTreeSet<String>,
4979        errors: &mut Vec<(String, Option<String>, String, bool, bool)>,
4980    ) {
4981        let mut schema_path_parts = Vec::new();
4982        if path.len() >= 2 && path[0] == "profile" {
4983            schema_path_parts.extend(path[2..].iter().cloned());
4984        } else {
4985            schema_path_parts.extend(path.iter().cloned());
4986        }
4987        let schema_path = schema_path_parts.join(".");
4988
4989        if let Some(valid_keys) = schema.get(&schema_path) {
4990            for (k, val) in table {
4991                if path.is_empty() && k == "profile" {
4992                    path.push(k.clone());
4993                    match val {
4994                        toml::Value::Table(t) => {
4995                            Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
4996                        }
4997                        toml::Value::Array(arr) => {
4998                            for item in arr {
4999                                if let toml::Value::Table(t) = item {
5000                                    Self::validate_toml_table(
5001                                        t,
5002                                        path,
5003                                        schema,
5004                                        plugin_config_roots,
5005                                        errors,
5006                                    );
5007                                }
5008                            }
5009                        }
5010                        _ => {}
5011                    }
5012                    path.pop();
5013                    continue;
5014                }
5015
5016                if valid_keys.contains(k) {
5017                    path.push(k.clone());
5018                    match val {
5019                        toml::Value::Table(t) => {
5020                            Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5021                        }
5022                        toml::Value::Array(arr) => {
5023                            for item in arr {
5024                                if let toml::Value::Table(t) = item {
5025                                    Self::validate_toml_table(
5026                                        t,
5027                                        path,
5028                                        schema,
5029                                        plugin_config_roots,
5030                                        errors,
5031                                    );
5032                                }
5033                            }
5034                        }
5035                        _ => {}
5036                    }
5037                    path.pop();
5038                } else if path.is_empty() && plugin_config_roots.contains(k) && val.is_table() {
5039                    // A plugin has declared this top-level root as its own config
5040                    // section, via `AppBuilder::config_section`. It is known and opaque:
5041                    // accept it and do not descend, because the plugin, not core, owns
5042                    // validation of its subtree. The `path.is_empty()` guard keeps this
5043                    // strictly the true top-level root (`[media]`, path `[]`), so a key
5044                    // that merely shares a plugin-root name while nested inside a known
5045                    // section is still validated normally.
5046                    //
5047                    // The `val.is_table()` guard keeps the exemption table-only:
5048                    // `config_section` declares a top-level config table, so a registered
5049                    // root written as a scalar or array (`media = "enabled"`, `media =
5050                    // ["a", "b"]`) is a malformed section that nothing would deserialize,
5051                    // and the app would boot on default plugin config. A non-table value
5052                    // therefore falls through to the normal unknown-root strict rejection
5053                    // below and fails loudly.
5054                    //
5055                    // A profile-prefixed plugin root (`[profile.<env>.media]`, path
5056                    // `["profile","<env>"]`) is deliberately not exempted and falls
5057                    // through to that same rejection: the plugin consumes only the
5058                    // top-level `[media]` table — its reader deserializes `root.media`
5059                    // directly and does not apply Autumn's profile merge — so exempting a
5060                    // profile layer the plugin cannot read would let a strict app with
5061                    // media settings only under `[profile.<env>.media]` boot silently on
5062                    // defaults. Profile-aware plugin config is a separate, larger
5063                    // enhancement. Deliberately not added to `valid_keys`, which would
5064                    // make the walk recurse and flag every plugin child as unknown.
5065                } else {
5066                    let mut full_path_parts = path.clone();
5067                    full_path_parts.push(k.clone());
5068                    let full_path = full_path_parts.join(".");
5069
5070                    let mut closest: Option<&str> = None;
5071                    let mut min_dist = usize::MAX;
5072                    for valid_key in valid_keys {
5073                        let dist = levenshtein(k, valid_key);
5074                        if dist <= 2 && dist < min_dist {
5075                            min_dist = dist;
5076                            closest = Some(valid_key);
5077                        }
5078                    }
5079
5080                    let suggestion = closest.map(|c| {
5081                        let mut sug_parts = path.clone();
5082                        sug_parts.push(c.to_string());
5083                        sug_parts.join(".")
5084                    });
5085
5086                    errors.push((
5087                        full_path,
5088                        suggestion,
5089                        schema_path.clone(),
5090                        val.is_table(),
5091                        path.is_empty(),
5092                    ));
5093                }
5094            }
5095        } else if path.len() == 1 && path[0] == "profile" {
5096            for (k, val) in table {
5097                if let toml::Value::Table(t) = val {
5098                    path.push(k.clone());
5099                    Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5100                    path.pop();
5101                } else {
5102                    let mut full_path_parts = path.clone();
5103                    full_path_parts.push(k.clone());
5104                    errors.push((
5105                        full_path_parts.join("."),
5106                        None,
5107                        schema_path.clone(),
5108                        val.is_table(),
5109                        path.is_empty(),
5110                    ));
5111                }
5112            }
5113        } else if path.is_empty() {
5114            let root_keys = schema.get("").cloned().unwrap_or_default();
5115            for (k, val) in table {
5116                if k == "profile" || root_keys.contains(k) {
5117                    path.push(k.clone());
5118                    match val {
5119                        toml::Value::Table(t) => {
5120                            Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5121                        }
5122                        toml::Value::Array(arr) => {
5123                            for item in arr {
5124                                if let toml::Value::Table(t) = item {
5125                                    Self::validate_toml_table(
5126                                        t,
5127                                        path,
5128                                        schema,
5129                                        plugin_config_roots,
5130                                        errors,
5131                                    );
5132                                }
5133                            }
5134                        }
5135                        _ => {}
5136                    }
5137                    path.pop();
5138                } else if plugin_config_roots.contains(k) && val.is_table() {
5139                    // A plugin has declared this top-level root as its own config
5140                    // section, via `AppBuilder::config_section`. It is known and opaque:
5141                    // accept it and do not descend, because the plugin owns validation of
5142                    // its subtree. Deliberately not injected into `root_keys`, which would
5143                    // make the walk recurse and flag every plugin child as unknown. The
5144                    // `val.is_table()` guard keeps the exemption table-only, since
5145                    // `config_section` declares a top-level `[media]` table: a registered
5146                    // root written as a scalar or array is malformed and falls through to
5147                    // the unknown-root strict rejection below rather than being exempted
5148                    // and booting on defaults.
5149                } else {
5150                    let mut closest: Option<&str> = None;
5151                    let mut min_dist = usize::MAX;
5152                    for valid_key in &root_keys {
5153                        let dist = levenshtein(k, valid_key);
5154                        if dist <= 2 && dist < min_dist {
5155                            min_dist = dist;
5156                            closest = Some(valid_key);
5157                        }
5158                    }
5159                    errors.push((
5160                        k.clone(),
5161                        closest.map(String::from),
5162                        schema_path.clone(),
5163                        val.is_table(),
5164                        path.is_empty(),
5165                    ));
5166                }
5167            }
5168        }
5169    }
5170
5171    /// Access the decrypted credentials store.
5172    ///
5173    /// Returns an empty store when no credentials file was found (the feature is opt-in).
5174    /// Use `config.credentials().get::<String>("stripe_key")` to access values.
5175    #[must_use]
5176    pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
5177        &self.credentials
5178    }
5179
5180    /// Load configuration with profile-aware layering.
5181    ///
5182    /// Applies the six-layer configuration system:
5183    /// 1. Framework defaults
5184    /// 2. Profile smart defaults (dev/prod)
5185    /// 3. `autumn.toml` (base config)
5186    /// 4. `[profile.{name}]` section in `autumn.toml`
5187    /// 5. `autumn-{profile}.toml` (legacy profile overrides)
5188    /// 6. `AUTUMN_*` environment variables
5189    ///
5190    /// # Errors
5191    ///
5192    /// Returns [`ConfigError::Io`] if a config file cannot be read,
5193    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
5194    /// [`ConfigError::Validation`] if a value is invalid.
5195    ///
5196    /// # Panics
5197    ///
5198    /// Panics if the internally-built TOML table fails to re-serialize
5199    /// (should never happen with well-formed profile defaults).
5200    pub fn load() -> Result<Self, ConfigError> {
5201        Self::load_policy(UnknownRootPolicy::Strict)
5202    }
5203
5204    /// Like [`load`](Self::load), but accepts unknown TOP-LEVEL config roots as
5205    /// opaque-with-a-warning instead of hard-failing.
5206    ///
5207    /// For tooling that cannot know the application's plugin set (e.g. the
5208    /// deploy CLI). Keeps STRICT validation of every known/core section
5209    /// `AutumnConfig` owns (`[server]`, `[database]`, `[deploy]`, …) — including
5210    /// child-key typos inside them — and still fails on malformed TOML; only a
5211    /// genuinely-unknown top-level root (very likely a plugin-owned table such
5212    /// as `[media]`) is spared, with a single doctor-style warning. App boot
5213    /// remains the authoritative strict gate for plugin-owned roots (see the
5214    /// `config_section` seam / #2061 / #1974 / #2063).
5215    ///
5216    /// # Errors
5217    ///
5218    /// Returns [`ConfigError::Io`] if a config file cannot be read,
5219    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
5220    /// [`ConfigError::Validation`] if a value is invalid (including an unknown
5221    /// key inside a known section under `strict_config`).
5222    ///
5223    /// # Panics
5224    ///
5225    /// Panics if the internally-built TOML table fails to re-serialize.
5226    pub fn load_lenient_unknown_roots() -> Result<Self, ConfigError> {
5227        Self::load_policy(UnknownRootPolicy::LenientWarn)
5228    }
5229
5230    fn load_policy(root_policy: UnknownRootPolicy) -> Result<Self, ConfigError> {
5231        // Feed a project-root `.env` into the `AUTUMN_*` env layer before
5232        // resolving config from the real environment. Rather than mutating the
5233        // process environment, `.env` values are layered *under* the real
5234        // environment via an overlay `Env`, so a real env var always wins. A
5235        // malformed `.env` fails loudly here rather than silently skipping
5236        // developer-provided values.
5237        let base = OsEnv;
5238        let profile = resolve_profile(&base);
5239        // Resolve `.env` from the same base directory config uses for
5240        // `autumn.toml` (AUTUMN_MANIFEST_DIR when set, else the process CWD),
5241        // so a binary launched from outside its crate root reads the `.env`
5242        // next to its config instead of the process working directory.
5243        let dir = crate::dotenv::dotenv_base_dir(&base);
5244        let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
5245            .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
5246        let env = crate::dotenv::DotenvEnv::new(&base, vars);
5247        // The zero-arg loaders (`load` / `load_lenient_unknown_roots`) have no
5248        // AppBuilder and therefore no plugin-declared config roots; plugin roots
5249        // arrive only via `TomlEnvConfigLoader::with_plugin_config_roots` →
5250        // `load_with_env_and_plugin_roots`. Pass an empty set here.
5251        Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
5252    }
5253
5254    /// Load configuration with profile-aware layering, using a provided
5255    /// environment abstraction instead of the OS environment. Useful for testing.
5256    ///
5257    /// # Errors
5258    /// Returns [`ConfigError::Io`] if a config file cannot be read,
5259    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
5260    /// [`ConfigError::Validation`] if a value is invalid.
5261    ///
5262    /// # Panics
5263    /// Panics if the internally-built TOML table fails to re-serialize.
5264    pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
5265        Self::load_with_env_and_plugin_roots_policy(
5266            env,
5267            &BTreeSet::new(),
5268            UnknownRootPolicy::Strict,
5269        )
5270    }
5271
5272    /// Like [`load_with_env`](Self::load_with_env), but treats each top-level
5273    /// root in `plugin_config_roots` as a **known, opaque** config table under
5274    /// `server.strict_config`.
5275    ///
5276    /// A plugin owns a top-level `[root]` table (e.g. `[media]`) that core's
5277    /// closed [`AutumnConfig`] schema knows nothing about. Without a
5278    /// registration seam, the strict unknown-key check hard-rejects that root as
5279    /// an unknown key and a plugin-enabled app cannot boot under
5280    /// `strict_config = true`. Passing the plugin's declared roots here exempts
5281    /// exactly those roots from the check: each listed root is accepted and its
5282    /// subtree is **not** descended into (the plugin, not core, validates its own
5283    /// section). Every other unknown root still hard-fails — the seam is
5284    /// fail-closed, never a blanket "allow unknown roots" escape hatch.
5285    ///
5286    /// This is the roots-aware path the [`AppBuilder`](crate::app::AppBuilder)
5287    /// wires up from [`config_section`](crate::app::AppBuilder::config_section)
5288    /// declarations; the plain [`load_with_env`](Self::load_with_env) delegates
5289    /// here with an empty set, so all existing callers are unaffected.
5290    ///
5291    /// # Errors
5292    /// Returns [`ConfigError::Io`] if a config file cannot be read,
5293    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
5294    /// [`ConfigError::Validation`] if a value is invalid.
5295    ///
5296    /// # Panics
5297    /// Panics if the internally-built TOML table fails to re-serialize.
5298    pub fn load_with_env_and_plugin_roots(
5299        env: &dyn Env,
5300        plugin_config_roots: &BTreeSet<String>,
5301    ) -> Result<Self, ConfigError> {
5302        Self::load_with_env_and_plugin_roots_policy(
5303            env,
5304            plugin_config_roots,
5305            UnknownRootPolicy::Strict,
5306        )
5307    }
5308
5309    /// Like [`load_with_env`](Self::load_with_env), but accepts unknown
5310    /// TOP-LEVEL config roots as opaque-with-a-warning instead of hard-failing.
5311    ///
5312    /// For tooling that cannot know the application's plugin set (e.g. the
5313    /// deploy CLI). Keeps STRICT validation of every known/core section — and
5314    /// of child-key typos inside them — and still fails on malformed TOML; only
5315    /// a genuinely-unknown top-level root (very likely a plugin-owned table such
5316    /// as `[media]`) is spared, with a single doctor-style warning. App boot
5317    /// remains the authoritative strict gate for plugin-owned roots (see the
5318    /// `config_section` seam / #2061 / #1974 / #2063).
5319    ///
5320    /// # Errors
5321    /// Returns [`ConfigError::Io`] if a config file cannot be read,
5322    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
5323    /// [`ConfigError::Validation`] if a value is invalid (including an unknown
5324    /// key inside a known section under `strict_config`).
5325    ///
5326    /// # Panics
5327    /// Panics if the internally-built TOML table fails to re-serialize.
5328    pub fn load_with_env_lenient_unknown_roots(env: &dyn Env) -> Result<Self, ConfigError> {
5329        Self::load_with_env_and_plugin_roots_policy(
5330            env,
5331            &BTreeSet::new(),
5332            UnknownRootPolicy::LenientWarn,
5333        )
5334    }
5335
5336    /// Shared config-loading worker threading BOTH the plugin-declared config
5337    /// roots (#2061) AND the unknown-top-level-root policy (#2063).
5338    ///
5339    /// The two knobs are orthogonal: `plugin_config_roots` exempts SPECIFIC
5340    /// declared table roots (they produce no error to classify), while
5341    /// `root_policy` decides whether the REMAINING unknown top-level roots
5342    /// hard-fail ([`Strict`](UnknownRootPolicy::Strict), app boot) or are
5343    /// accepted opaque-with-a-warning
5344    /// ([`LenientWarn`](UnknownRootPolicy::LenientWarn), the deploy CLI).
5345    fn load_with_env_and_plugin_roots_policy(
5346        env: &dyn Env,
5347        plugin_config_roots: &BTreeSet<String>,
5348        root_policy: UnknownRootPolicy,
5349    ) -> Result<Self, ConfigError> {
5350        let selected_profile_input = resolve_profile_input(env);
5351        let profile =
5352            normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
5353        let mut has_inline_profile_section = false;
5354
5355        // Build merged TOML:
5356        // profile smart defaults ← autumn.toml ← [profile.{name}] ← autumn-{profile}.toml
5357        let mut merged = profile_defaults_as_toml(&profile);
5358
5359        // Layer 3: base autumn.toml
5360        if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
5361            deep_merge(&mut merged, base.clone());
5362
5363            // Layer 4: [profile.{name}] in autumn.toml
5364            for profile_name in profile_lookup_names(&profile) {
5365                if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
5366                    deep_merge(&mut merged, inline_profile);
5367                    has_inline_profile_section = true;
5368                }
5369            }
5370        }
5371
5372        // Layer 5: autumn-{profile}.toml (legacy compatibility)
5373        let mut has_profile_file = false;
5374        for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
5375            let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
5376            if let Some(profile_toml) = load_raw_toml(&profile_path)? {
5377                deep_merge(&mut merged, profile_toml);
5378                has_profile_file = true;
5379                break;
5380            }
5381        }
5382        if !has_profile_file
5383            && should_warn_missing_profile_file(&profile, has_inline_profile_section)
5384        {
5385            warn_profile_typo(&profile);
5386        }
5387
5388        // Deserialize the merged TOML table into AutumnConfig
5389        let toml_str =
5390            toml::to_string(&merged).expect("internal error: failed to serialize merged config");
5391        let mut config: Self = toml::from_str(&toml_str)?;
5392        config.profile = Some(profile);
5393
5394        // Layer 6: env var overrides (highest priority)
5395        config.apply_env_overrides_with_env(env);
5396
5397        // Env overrides already applied `AUTUMN_SERVER__STRICT_CONFIG`.
5398        if config.server.strict_config {
5399            let enforce_all = config.server.strict_config_enforce_all
5400                || env
5401                    .var("AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL")
5402                    .is_ok_and(|v| v == "true" || v == "1");
5403            Self::run_strict_unknown_key_check(
5404                &toml_str,
5405                enforce_all,
5406                plugin_config_roots,
5407                root_policy,
5408            )?;
5409        }
5410
5411        // ── Deprecation channel (purely additive; never mutates `config`). ──────
5412        // Emit exactly one structured WARN per deprecated key that is present in
5413        // the resolved config (via TOML or env var). The old value is already
5414        // honoured above; this is observation only.
5415        let empty_table = toml::Table::new();
5416        let merged_table = merged.as_table().unwrap_or(&empty_table);
5417        for f in detect_deprecated_keys(merged_table, env, DEPRECATED_CONFIG_KEYS) {
5418            // eprintln! ensures the warning is visible on stderr even before the
5419            // tracing subscriber is installed (config loads before telemetry init in
5420            // the normal startup path).  The tracing::warn! below is kept so apps
5421            // that pre-install their own subscriber still receive structured events.
5422            eprintln!(
5423                "Warning: deprecated configuration key `{}` is still honored but will be removed \
5424                 in {}; deprecated since {} (replacement: {}; source: {:?})",
5425                f.path,
5426                f.remove_in,
5427                f.since,
5428                f.replacement.as_deref().unwrap_or("none — remove this key"),
5429                f.source,
5430            );
5431            tracing::warn!(
5432                deprecated_key = f.path.as_str(),
5433                replacement = f.replacement.as_deref().unwrap_or("none; remove this key"),
5434                since = f.since.as_str(),
5435                remove_in = f.remove_in.as_str(),
5436                source = ?f.source,
5437                "deprecated configuration key in use; it is still honored but scheduled for removal"
5438            );
5439        }
5440
5441        #[cfg(feature = "mail")]
5442        if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
5443            config.mail.transport = crate::mail::Transport::Log;
5444        }
5445
5446        config.validate()?;
5447
5448        let base_dir: PathBuf = env
5449            .var("AUTUMN_MANIFEST_DIR")
5450            .map_or_else(|_| PathBuf::from("."), PathBuf::from);
5451        let cred_profile = config.profile.as_deref().unwrap_or("dev");
5452        let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
5453        config.credentials = crate::credentials::load_credentials_with_key_override(
5454            cred_profile,
5455            &base_dir,
5456            master_key_override.as_deref(),
5457        )
5458        .map_err(|e| ConfigError::Credentials(e.to_string()))?;
5459
5460        #[cfg(feature = "oauth2")]
5461        {
5462            config.expand_oauth2_providers();
5463        }
5464
5465        Ok(config)
5466    }
5467
5468    /// Runs the strict unknown-key check against the merged `toml_str`.
5469    ///
5470    /// Unknown keys are partitioned by [`unknown_key_was_previously_strict`]:
5471    /// keys whose section was already strictly validated before the #1890
5472    /// schema-walk fix (or all keys when `enforce_all` is set) hard-fail; keys
5473    /// in sections that only became covered by the fix are warned about but
5474    /// tolerated for one release (warn-first rollout).
5475    fn run_strict_unknown_key_check(
5476        toml_str: &str,
5477        enforce_all: bool,
5478        plugin_config_roots: &BTreeSet<String>,
5479        root_policy: UnknownRootPolicy,
5480    ) -> Result<(), ConfigError> {
5481        let schema = Self::get_schema_keys();
5482        let errors = Self::validate_toml_detailed(toml_str, &schema, plugin_config_roots);
5483
5484        let mut hard_errors = Vec::new();
5485        let mut warn_only = Vec::new();
5486        let mut opaque_roots = Vec::new();
5487        for (path, sug, schema_parent, is_table, is_top_level) in errors {
5488            // Deploy-CLI leniency (#2063/#2067): a genuinely-unknown true top-level
5489            // root — one whose actual path is a bare root key, with no `profile.<name>`
5490            // prefix, and whose schema parent is the document root `""` — is accepted
5491            // as opaque rather than failing. It is almost certainly a plugin-owned
5492            // config table such as `[media]` that the CLI structurally cannot know
5493            // about, and app boot stays the strict gate for it.
5494            //
5495            // Top-level-ness is structural, taken from the error's `is_top_level` flag,
5496            // set when the offending key's parent path was empty at push time. That is
5497            // the same signal #2061's app-boot exemption keys on (`path.is_empty()` in
5498            // `validate_toml_table`). It is deliberately not inferred from the rendered
5499            // dotted `path` string, which is ambiguous: a legitimately quoted-dotted
5500            // top-level key like `["my.plugin"]`, from `config_section("my.plugin")`,
5501            // and a two-level path both render `my.plugin`. The earlier
5502            // `!path.contains('.')` heuristic therefore hard-failed a quoted-dotted
5503            // top-level plugin root at deploy even though app boot accepts it.
5504            //
5505            // Nor is it merely `schema_parent.is_empty()`. `validate_toml_detailed`
5506            // reports an empty schema parent for a profile-prefixed section like
5507            // `[profile.prod.media]` too, because the profile prefix is stripped before
5508            // root-schema validation. But such a section is pushed with a non-empty
5509            // parent path (`["profile","prod"]`), so `is_top_level` is false for it, and
5510            // the deployed app — whose `config_section` seam exempts only the true
5511            // top-level `[media]` via `path.is_empty()` — still rejects it at boot. So
5512            // deploy and app boot agree: both accept top-level `[media]` and
5513            // `["my.plugin"]`, and both reject `[profile.prod.media]`.
5514            //
5515            // A profile-prefixed root therefore falls through to the normal hard
5516            // classification below (schema_parent `""` ∈ `PRE_1890_STRICT_PARENTS`) and
5517            // is strictly rejected at deploy, matching app boot. Only a true root is
5518            // spared: an unknown key inside a known section, with a non-empty
5519            // schema_parent, also falls through, so a `[database] primry_url` typo still
5520            // hard-fails. The app-boot path passes `Strict`, so its behavior is unchanged.
5521            //
5522            // Leniency also requires the root's TOML value to be a table, mirroring the
5523            // #2061 app-boot exemption. A non-table true top-level root — `media =
5524            // "enabled"`, `media = ["a", "b"]` — is a malformed section nothing would
5525            // deserialize, so it falls through to the hard classification exactly as the
5526            // deployed app rejects it. Without this check, deploy would accept a
5527            // non-table root that boot rejects, reopening the gap this branch closes.
5528            if root_policy == UnknownRootPolicy::LenientWarn
5529                && schema_parent.is_empty()
5530                && is_top_level
5531                && is_table
5532            {
5533                opaque_roots.push(path);
5534                continue;
5535            }
5536            if enforce_all || unknown_key_was_previously_strict(&schema_parent) {
5537                hard_errors.push((path, sug));
5538            } else {
5539                warn_only.push((path, sug));
5540            }
5541        }
5542
5543        // Deploy-CLI opaque top-level roots (#2063): surface exactly one
5544        // doctor-style line (never fatal) so an accepted plugin root is
5545        // observable and a typo'd root is not silently swallowed — it will be
5546        // rejected authoritatively when the app itself boots. `eprintln!`
5547        // guarantees visibility before a tracing subscriber is installed; the
5548        // `tracing::warn!` keeps structured output for apps that pre-install one.
5549        if !opaque_roots.is_empty() {
5550            let roots = opaque_roots.join(", ");
5551            let count = opaque_roots.len();
5552            eprintln!(
5553                "deploy config: accepting {count} unknown top-level config section(s) as \
5554                 opaque — the deployed app runs the authoritative strict check, so each must \
5555                 be a section the app declares (e.g. a plugin config table) or the app will \
5556                 reject it at boot: {roots}. A typo here will make the app fail to start."
5557            );
5558            tracing::warn!(
5559                unknown_top_level_roots = roots.as_str(),
5560                count,
5561                "deploy config: accepting unknown top-level config section(s) as opaque; the \
5562                 deployed app runs the authoritative strict check, so each must be a section \
5563                 the app declares (e.g. a plugin config table) or it will reject it at boot — \
5564                 a typo here will make the app fail to start (#2063)"
5565            );
5566        }
5567
5568        // Warn-first rollout (#1890): unknown keys in sections that only became
5569        // strictly validated by the schema-walk fix are surfaced loudly but do not fail
5570        // startup for one release, so configs that silently passed before keep booting.
5571        // `eprintln!` guarantees visibility before the tracing subscriber is installed;
5572        // the `tracing::warn!` keeps structured output for apps that pre-install one.
5573        // Set `server.strict_config_enforce_all = true`, or
5574        // `AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL=1`, to promote these to hard errors.
5575        for (path, sug) in &warn_only {
5576            let hint = sug
5577                .as_deref()
5578                .map_or_else(String::new, |s| format!(" — did you mean \"{s}\"?"));
5579            eprintln!(
5580                "Warning: unknown configuration key \"{path}\"{hint}. It is ignored and \
5581                 falls back to defaults. This will become a hard error in a future \
5582                 release; set server.strict_config_enforce_all = true to enforce now."
5583            );
5584            tracing::warn!(
5585                unknown_key = path.as_str(),
5586                suggestion = sug.as_deref().unwrap_or(""),
5587                "unknown configuration key in a section newly covered by strict \
5588                 validation; ignored for now (warn-first rollout, #1890), will hard-fail \
5589                 once enforcement is promoted"
5590            );
5591        }
5592
5593        if !hard_errors.is_empty() {
5594            let err_messages: Vec<String> = hard_errors
5595                .into_iter()
5596                .map(|(path, sug)| {
5597                    sug.map_or_else(
5598                        || format!("unknown key \"{path}\""),
5599                        |s| format!("unknown key \"{path}\" — did you mean \"{s}\"?"),
5600                    )
5601                })
5602                .collect();
5603            return Err(ConfigError::Validation(format!(
5604                "Strict config check failed. Unknown keys in configuration: {}",
5605                err_messages.join(", ")
5606            )));
5607        }
5608        Ok(())
5609    }
5610
5611    /// Helper method to expand `OAuth2` preset configurations and resolve credentials-backed values.
5612    #[cfg(feature = "oauth2")]
5613    fn expand_oauth2_providers(&mut self) {
5614        let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
5615        for name in provider_names {
5616            // 1. Expand from preset if available
5617            if let (Some(preset), Some(p)) = (
5618                crate::auth::provider_preset(&name),
5619                self.auth.oauth2.providers.get_mut(&name),
5620            ) {
5621                if p.authorize_url.is_empty() {
5622                    p.authorize_url = preset.authorize_url;
5623                }
5624                if p.token_url.is_empty() {
5625                    p.token_url = preset.token_url;
5626                }
5627                if p.userinfo_url.is_none() {
5628                    p.userinfo_url = preset.userinfo_url;
5629                }
5630                if p.scope.is_empty() || p.scope == "default" {
5631                    p.scope = preset.scope;
5632                }
5633                if p.issuer.is_none() {
5634                    p.issuer = preset.issuer;
5635                }
5636                if p.jwks_url.is_none() {
5637                    p.jwks_url = preset.jwks_url;
5638                }
5639                if p.discovery_url.is_none() {
5640                    p.discovery_url = preset.discovery_url;
5641                }
5642            }
5643
5644            // 2. Resolve credentials-backed secrets/IDs
5645            if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
5646                let normalized_name = name
5647                    .chars()
5648                    .map(|c| if c.is_alphanumeric() { c } else { '_' })
5649                    .collect::<String>()
5650                    .to_lowercase();
5651
5652                let id_key = format!("oauth2_{normalized_name}_client_id");
5653                if p.client_id.is_empty() {
5654                    if let Some(id) = self.credentials.get::<String>(&id_key) {
5655                        p.client_id = id;
5656                    } else if let Some(id) = self
5657                        .credentials
5658                        .get::<String>(&format!("oauth2_{name}_client_id"))
5659                    {
5660                        p.client_id = id;
5661                    }
5662                }
5663                let secret_key = format!("oauth2_{normalized_name}_client_secret");
5664                if p.client_secret.is_empty() {
5665                    if let Some(secret) = self.credentials.get::<String>(&secret_key) {
5666                        p.client_secret = secret;
5667                    } else if let Some(secret) = self
5668                        .credentials
5669                        .get::<String>(&format!("oauth2_{name}_client_secret"))
5670                    {
5671                        p.client_secret = secret;
5672                    }
5673                }
5674            }
5675        }
5676    }
5677
5678    /// Load configuration from a specific TOML file path.
5679    ///
5680    /// Used internally and for testing. Does **not** apply profile
5681    /// layering or environment overrides. Prefer [`load()`](Self::load)
5682    /// in application code.
5683    ///
5684    /// # Errors
5685    ///
5686    /// Returns [`ConfigError::Io`] if the file cannot be read, or
5687    /// [`ConfigError::Parse`] if the file contains invalid TOML.
5688    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
5689        match std::fs::read_to_string(path) {
5690            Ok(contents) => {
5691                let config: Self = toml::from_str(&contents)?;
5692                config.validate()?;
5693                Ok(config)
5694            }
5695            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
5696            Err(e) => Err(ConfigError::Io(e)),
5697        }
5698    }
5699
5700    /// Validate the resolved configuration for semantic errors.
5701    ///
5702    /// # Errors
5703    /// Returns [`ConfigError::Validation`] when a field combination is
5704    /// syntactically well-formed TOML but semantically invalid.
5705    pub fn validate(&self) -> Result<(), ConfigError> {
5706        self.database.validate()?;
5707        self.server.http.validate()?;
5708        self.realtime.validate()?;
5709        self.cors.validate()?;
5710        self.server.admission.validate()?;
5711        #[cfg(feature = "http-client")]
5712        self.http.client.adaptive_throttle.validate()?;
5713        #[cfg(feature = "http-client")]
5714        self.http.client.retry_budget.validate()?;
5715        self.scheduler.validate()?;
5716        self.outbox.validate()?;
5717        // #1605: reject an unparseable or zero retention window at boot rather
5718        // than silently skipping the dataset it names — a policy an operator
5719        // believes is enforced but isn't is worse than no policy.
5720        self.retention.validate()?;
5721        self.validate_retention_against_replay_protection()?;
5722        // Framework state (autumn_jobs, the scheduler tick table) lives on
5723        // the control topology and is never sharded. Sharded apps that use a
5724        // Postgres-backed jobs or scheduler backend therefore need a control
5725        // role alongside their shards.
5726        if self.database.has_shards()
5727            && self.database.effective_primary_url().is_none()
5728            && (self.scheduler.backend == SchedulerBackend::Postgres
5729                || self.jobs.backend == "postgres")
5730        {
5731            return Err(ConfigError::Validation(
5732                "jobs/scheduler require a control database: set database.primary_url (or \
5733                 database.url) alongside [[database.shards]] — framework state such as \
5734                 autumn_jobs and scheduler locks is not sharded (see docs/guide/sharding.md)"
5735                    .to_owned(),
5736            ));
5737        }
5738        // Shard-local jobs (#3072) run on the Postgres backend, one job table
5739        // for each shard.
5740        if self.jobs.postgres.shard_local
5741            && (self.jobs.backend != "postgres" || !self.database.has_shards())
5742        {
5743            return Err(ConfigError::Validation(
5744                "jobs.postgres.shard_local = true needs jobs.backend = \"postgres\" and \
5745                 [[database.shards]] (see docs/guide/cell-isolation.md)"
5746                    .to_owned(),
5747            ));
5748        }
5749        let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
5750        self.security
5751            .webhooks
5752            .validate(is_production)
5753            .map_err(|error| ConfigError::Validation(error.to_string()))?;
5754        #[cfg(feature = "mail")]
5755        self.mail.validate(self.profile.as_deref())?;
5756        self.time_zone.validate()?;
5757        // A `[shadow]` block that is switched on but cannot be honoured (no
5758        // target, an unusable URL, an out-of-range sample rate) must fail boot
5759        // rather than start a replica that silently mirrors nothing.
5760        self.shadow.validate().map_err(ConfigError::Validation)?;
5761        // Faults in prod need an explicit `allow_in_production = true`.
5762        self.fault_injection
5763            .validate(self.profile.as_deref())
5764            .map_err(ConfigError::Validation)?;
5765        // Fail fast on an insecure or flapping [cluster] section: a node that
5766        // would boot without a shared secret must not boot at all.
5767        self.cluster.validate()?;
5768        // A zero or absurd `[metrics]` cap is refused here rather than clamped
5769        // at apply time, so `autumn check` names the key. A cap of 0 would
5770        // silently drop every labeled sample the app records.
5771        self.metrics.validate()?;
5772        // A zero ping limit fails every ping, so `/ready` is never `200`.
5773        self.health.validate()?;
5774        // A zero recheck would spin; a negative threshold keeps deferrable work
5775        // waiting for ever, because the runtime-config signal is never below 0.
5776        self.cost.validate().map_err(ConfigError::Validation)?;
5777        // An x402 route that can never match serves its handler free.
5778        self.aeo.validate().map_err(ConfigError::Validation)?;
5779        // A `[replication]` block that is switched on but cannot ship (no
5780        // destination, both destinations, no credential indirection) must fail
5781        // here — so `autumn check` and `autumn doctor` see it too — rather than
5782        // only when the app itself boots (#1628).
5783        if let Some(replication) = &self.replication {
5784            let errors = replication.validation_errors();
5785            if !errors.is_empty() {
5786                return Err(ConfigError::Validation(format!(
5787                    "invalid [replication] configuration:\n  - {}",
5788                    errors.join("\n  - ")
5789                )));
5790            }
5791        }
5792        // Session backend validation deliberately lives in
5793        // `crate::session::build_session_layer`, not here. That function
5794        // short-circuits when a custom `SessionStore` was installed via
5795        // `AppBuilder::with_session_store(...)`, so a then-irrelevant
5796        // `session.backend = "redis"` without a redis URL need not fail the
5797        // boot. Validating it here would defeat the override and exit before
5798        // the custom store applies. The "prod profile plus memory backend"
5799        // warning lives there for the same reason.
5800        Ok(())
5801    }
5802
5803    /// Reject a `retention.webhook_replay` window shorter than any configured
5804    /// endpoint's replay-rejection window (issue #1605).
5805    ///
5806    /// A retention window is a *compliance* knob. Letting it silently shorten
5807    /// the lifetime of a replay marker would weaken a *security* control
5808    /// through a door nobody would think to look behind: once the marker is
5809    /// gone, a captured request replayed after that point is accepted again.
5810    /// Fail closed and name the knob to lower instead
5811    /// (`replay_window_secs`), rather than quietly reducing replay
5812    /// protection.
5813    ///
5814    /// Endpoints registered in code rather than in `autumn.toml` are not
5815    /// visible here; `docs/guide/data-retention.md` states the same rule for
5816    /// them.
5817    fn validate_retention_against_replay_protection(&self) -> Result<(), ConfigError> {
5818        let Some(window) = self.retention.window("webhook_replay") else {
5819            return Ok(());
5820        };
5821        for endpoint in &self.security.webhooks.endpoints {
5822            if !endpoint.replay_protection {
5823                continue;
5824            }
5825            let replay = std::time::Duration::from_secs(endpoint.replay_window_secs);
5826            if window < replay {
5827                return Err(ConfigError::Validation(format!(
5828                    "retention.webhook_replay ({window_secs}s) is shorter than the \
5829                     replay_window_secs ({replay_secs}s) of webhook endpoint {name:?}: \
5830                     expiring replay markers early would silently weaken replay \
5831                     protection. Lower that endpoint's replay_window_secs instead, or \
5832                     widen retention.webhook_replay (see \
5833                     docs/guide/data-retention.md).",
5834                    window_secs = window.as_secs(),
5835                    replay_secs = endpoint.replay_window_secs,
5836                    name = endpoint.name,
5837                )));
5838            }
5839        }
5840        Ok(())
5841    }
5842
5843    /// Tighten the TTL-native subsystem knobs to their `[retention]` window
5844    /// (issue #1605).
5845    ///
5846    /// The `idempotency` and `sessions` datasets are expired by their own
5847    /// storage rather than by a sweep, so their retention window is enforced
5848    /// by writing records with a shorter lifetime. Applying the cap once, to
5849    /// the loaded config, means every derived lifetime inherits it: the
5850    /// idempotency layer's TTL, and the session cookie's `Max-Age` together
5851    /// with the Redis session TTL.
5852    ///
5853    /// Note that capping `sessions` shortens how long a signed-in user stays
5854    /// signed in — see `docs/guide/data-retention.md`.
5855    ///
5856    /// **`job_tracking` is deliberately not capped here.** It is
5857    /// sweep-enforced, and the sweep honours a GDPR legal hold on
5858    /// `autumn_job_tracking` while `job.rs`'s independent `expires_at`
5859    /// cleanup cannot — capping `jobs.tracking.ttl_secs` would let that
5860    /// cleanup delete, on exactly the retention schedule, the very rows the
5861    /// retention report says are being preserved under hold.
5862    ///
5863    /// Always a `min`, so this can only ever *shorten* a lifetime — there is
5864    /// no configuration in which declaring `[retention]` causes data to be
5865    /// kept longer than it is today — and it is idempotent, so calling it
5866    /// twice is harmless.
5867    ///
5868    /// Called on the loaded config during boot; apps do not call this
5869    /// directly.
5870    pub fn apply_retention_caps(&mut self) {
5871        // `job_tracking` is capped only when its records do NOT live in
5872        // `autumn_job_tracking`. Under `jobs.backend = "postgres"` the sweep
5873        // enforces the window and a GDPR legal hold can stop it, so capping
5874        // the TTL there would let the job runner's independent `expires_at`
5875        // cleanup delete held rows on exactly the retention schedule. Under
5876        // any other backend (redis, or the in-memory fallback) there is no
5877        // table to sweep and the record's TTL is the only bound there is —
5878        // leaving it uncapped would claim a window nothing enforces.
5879        let cap_job_tracking = self.jobs.backend != "postgres";
5880        let caps: [(&str, &mut u64); 3] = [
5881            ("idempotency", &mut self.idempotency.ttl_secs),
5882            ("sessions", &mut self.session.max_age_secs),
5883            ("job_tracking", &mut self.jobs.tracking.ttl_secs),
5884        ];
5885        for (key, target) in caps {
5886            if key == "job_tracking" && !cap_job_tracking {
5887                continue;
5888            }
5889            if let Some(window) = self.retention.window(key) {
5890                *target = (*target).min(window.as_secs());
5891            }
5892        }
5893    }
5894
5895    /// Apply environment variable overrides to the loaded config.
5896    ///
5897    /// All fields can be overridden via `AUTUMN_SECTION__FIELD` environment
5898    /// variables. Double underscore `__` separates nested config sections.
5899    ///
5900    /// # Server
5901    /// - `AUTUMN_SERVER__PORT` → `server.port` (u16)
5902    /// - `AUTUMN_SERVER__HOST` → `server.host` (String)
5903    /// - `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` → `server.shutdown_timeout_secs` (u64)
5904    /// - `AUTUMN_SERVER__PRESTOP_GRACE_SECS` → `server.prestop_grace_secs` (u64)
5905    /// - `AUTUMN_SERVER__UPGRADE__ENABLED` → `server.upgrade.enabled` (bool)
5906    /// - `AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS` →
5907    ///   `server.upgrade.ready_timeout_secs` (u64)
5908    /// - `AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS`,
5909    ///   `AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS`,
5910    ///   `AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES`,
5911    ///   `AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS`,
5912    ///   `AUTUMN_SERVER__HTTP__MAX_CONNECTIONS` → `server.http.*`
5913    ///
5914    /// # Database
5915    /// - `AUTUMN_DATABASE__PRIMARY_URL` -> `database.primary_url` (String)
5916    /// - `AUTUMN_DATABASE__REPLICA_URL` -> `database.replica_url` (String)
5917    /// - `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` -> `database.primary_pool_size` (usize)
5918    /// - `AUTUMN_DATABASE__REPLICA_POOL_SIZE` -> `database.replica_pool_size` (usize)
5919    /// - `AUTUMN_DATABASE__REPLICA_FALLBACK` -> `database.replica_fallback` (`fail_readiness` | `primary`)
5920    /// - `AUTUMN_DATABASE__REPLICA_MAX_LAG_MS` -> `database.replica_max_lag_ms` (u64)
5921    /// - `AUTUMN_DATABASE__WARN_ON_POOLER` -> `database.warn_on_pooler` (bool)
5922    /// - `AUTUMN_DATABASE__URL` → `database.url` (String)
5923    /// - `AUTUMN_DATABASE__POOL_SIZE` → `database.pool_size` (usize)
5924    /// - `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` → `database.connect_timeout_secs` (u64)
5925    /// - `AUTUMN_DATABASE__STARTUP_WAIT_SECS` → `database.startup_wait_secs` (u64)
5926    /// - `AUTUMN_DATABASE__AUTO_MIGRATE` -> `database.auto_migrate` (`Option<bool>`)
5927    /// - `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` -> `database.auto_migrate_in_production` (bool)
5928    ///
5929    /// # Log
5930    /// - `AUTUMN_LOG__LEVEL` → `log.level` (String, tracing filter directive)
5931    /// - `AUTUMN_LOG__FORMAT` → `log.format` (Auto | Pretty | Json)
5932    ///
5933    /// # Telemetry
5934    /// - `AUTUMN_TELEMETRY__ENABLED` -> `telemetry.enabled` (bool)
5935    /// - `AUTUMN_TELEMETRY__SERVICE_NAME` -> `telemetry.service_name` (String)
5936    /// - `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` -> `telemetry.service_namespace` (String)
5937    /// - `AUTUMN_TELEMETRY__SERVICE_VERSION` -> `telemetry.service_version` (String)
5938    /// - `AUTUMN_TELEMETRY__ENVIRONMENT` -> `telemetry.environment` (String)
5939    /// - `AUTUMN_TELEMETRY__OTLP_ENDPOINT` -> `telemetry.otlp_endpoint` (String)
5940    /// - `AUTUMN_TELEMETRY__PROTOCOL` -> `telemetry.protocol` (`Grpc` | `HttpProtobuf`)
5941    /// - `AUTUMN_TELEMETRY__STRICT` -> `telemetry.strict` (bool)
5942    /// - `AUTUMN_TELEMETRY__SAMPLE_RATIO` -> `telemetry.sample_ratio` (f64)
5943    ///
5944    /// # Health / Probes
5945    /// - `AUTUMN_HEALTH__PATH` → `health.path` (String)
5946    /// - `AUTUMN_HEALTH__LIVE_PATH` → `health.live_path` (String)
5947    /// - `AUTUMN_HEALTH__READY_PATH` → `health.ready_path` (String)
5948    /// - `AUTUMN_HEALTH__STARTUP_PATH` → `health.startup_path` (String)
5949    /// - `AUTUMN_HEALTH__DETAILED` → `health.detailed` (bool)
5950    /// - `AUTUMN_HEALTH__ENABLED` → `health.enabled` (bool)
5951    /// - `AUTUMN_HEALTH__CACHE_TTL_MS` → `health.cache_ttl_ms` (u64)
5952    /// - `AUTUMN_HEALTH__PING_TIMEOUT_MS` → `health.ping_timeout_ms` (u64)
5953    /// - `AUTUMN_HEALTH__REDIS_READINESS` → `health.redis_readiness` (bool)
5954    /// - `AUTUMN_HEALTH__DB_READINESS` → `health.db_readiness` (bool)
5955    ///
5956    /// # Jobs
5957    /// - `AUTUMN_JOBS__BACKEND` → `jobs.backend` (`local` / `redis` / `sqlite`)
5958    /// - `AUTUMN_JOBS__WORKERS` → `jobs.workers` (`usize`)
5959    /// - `AUTUMN_JOBS__PIN` → `jobs.pin` (comma-separated queue names)
5960    /// - `AUTUMN_JOBS__MAX_ATTEMPTS` → `jobs.max_attempts` (`u32`)
5961    /// - `AUTUMN_JOBS__INITIAL_BACKOFF_MS` → `jobs.initial_backoff_ms` (`u64`)
5962    /// - `AUTUMN_JOBS__MAX_BACKOFF_MS` → `jobs.max_backoff_ms` (`u64`)
5963    /// - `AUTUMN_JOBS__DEFAULT_TIMEOUT_MS` → `jobs.default_timeout_ms` (`u64`)
5964    /// - `AUTUMN_JOBS__REDIS__URL` → `jobs.redis.url` (`String`)
5965    /// - `AUTUMN_JOBS__REDIS__KEY_PREFIX` → `jobs.redis.key_prefix` (`String`)
5966    /// - `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` → `jobs.redis.visibility_timeout_ms` (`u64`)
5967    /// - `AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT` → `jobs.redis.dead_letter_limit` (`usize`, `0` = unbounded)
5968    /// - `AUTUMN_JOBS__TRACKING__TTL_SECS` → `jobs.tracking.ttl_secs` (`u64`)
5969    /// - `AUTUMN_JOBS__TRACKING__ROUTE_ENABLED` → `jobs.tracking.route_enabled` (`bool`)
5970    ///
5971    /// # Outbox (issue #3062)
5972    /// - `AUTUMN_OUTBOX__ENABLED` → `outbox.enabled` (`bool`)
5973    /// - `AUTUMN_OUTBOX__POLL_INTERVAL_MS` → `outbox.poll_interval_ms` (`u64`)
5974    /// - `AUTUMN_OUTBOX__BATCH_SIZE` → `outbox.batch_size` (`usize`)
5975    /// - `AUTUMN_OUTBOX__MAX_ATTEMPTS` → `outbox.max_attempts` (`u32`)
5976    /// - `AUTUMN_OUTBOX__INITIAL_BACKOFF_MS` → `outbox.initial_backoff_ms` (`u64`)
5977    /// - `AUTUMN_OUTBOX__MAX_BACKOFF_MS` → `outbox.max_backoff_ms` (`u64`)
5978    /// - `AUTUMN_OUTBOX__LEASE_MS` → `outbox.lease_ms` (`u64`)
5979    /// - `AUTUMN_OUTBOX__RETENTION_MS` → `outbox.retention_ms` (`u64`)
5980    ///
5981    /// # Retention (issue #1605)
5982    /// - `AUTUMN_RETENTION__SWEEP_INTERVAL` → `retention.sweep_interval` (duration `String`)
5983    /// - `AUTUMN_RETENTION__JOB_HISTORY` → `retention.job_history` (duration `String`)
5984    /// - `AUTUMN_RETENTION__COMMIT_HOOKS` → `retention.commit_hooks` (duration `String`)
5985    /// - `AUTUMN_RETENTION__JOB_TRACKING` → `retention.job_tracking` (duration `String`)
5986    /// - `AUTUMN_RETENTION__IDEMPOTENCY` → `retention.idempotency` (duration `String`)
5987    /// - `AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS` → `retention.experiment_assignments` (duration `String`)
5988    /// - `AUTUMN_RETENTION__WEBHOOK_REPLAY` → `retention.webhook_replay` (duration `String`)
5989    /// - `AUTUMN_RETENTION__SESSIONS` → `retention.sessions` (duration `String`)
5990    /// - `AUTUMN_RETENTION__AUDIT_ARCHIVES` → `retention.audit_archives` (duration `String`)
5991    /// - `AUTUMN_RETENTION__CUSTOM_DOMAINS` → `retention.custom_domains` (duration `String`)
5992    ///
5993    /// # Signed webhooks
5994    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` -> `security.webhooks.replay.backend` (`memory` / `redis`)
5995    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` -> `security.webhooks.replay.redis.url` (`String`)
5996    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` -> `security.webhooks.replay.redis.key_prefix` (`String`)
5997    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` -> `security.webhooks.replay.allow_memory_in_production` (`bool`)
5998    ///
5999    /// # Cluster
6000    /// - `AUTUMN_CLUSTER__ENABLED` → `cluster.enabled` (`bool`)
6001    /// - `AUTUMN_CLUSTER__SECRET` → `cluster.secret` (`SecretString`)
6002    /// - `AUTUMN_CLUSTER__CLUSTER_NAME` → `cluster.cluster_name` (`String`)
6003    /// - `AUTUMN_CLUSTER__BIND_ADDR` → `cluster.bind_addr` (`String`)
6004    /// - `AUTUMN_CLUSTER__ADVERTISE_ADDR` → `cluster.advertise_addr` (`String`)
6005    /// - `AUTUMN_CLUSTER__SEED_PEERS` → `cluster.seed_peers` (comma-separated addresses)
6006    /// - `AUTUMN_CLUSTER__NODE_ID` → `cluster.node_id` (`String`)
6007    /// - `AUTUMN_CLUSTER__PUSH_INTERVAL_MS` → `cluster.push_interval_ms` (`u64`)
6008    /// - `AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS` → `cluster.suspicion_timeout_ms` (`u64`)
6009    /// - `AUTUMN_SHADOW__ENABLED` → `shadow.enabled` (`bool`)
6010    /// - `AUTUMN_SHADOW__TARGET` → `shadow.target` (`String`)
6011    /// - `AUTUMN_SHADOW__SAMPLE_RATE` → `shadow.sample_rate` (`f64`)
6012    /// - `AUTUMN_SHADOW__ROUTES` → `shadow.routes` (comma-separated patterns)
6013    /// - `AUTUMN_SHADOW__TIMEOUT_MS` → `shadow.timeout_ms` (`u64`)
6014    /// - `AUTUMN_SHADOW__MAX_IN_FLIGHT` → `shadow.max_in_flight` (`usize`)
6015    /// - `AUTUMN_SHADOW__MAX_BODY_BYTES` → `shadow.max_body_bytes` (`usize`)
6016    /// - `AUTUMN_SHADOW__MAX_RECORDS` → `shadow.max_records` (`usize`)
6017    /// - `AUTUMN_SHADOW__MAX_SAMPLE_BYTES` → `shadow.max_sample_bytes` (`usize`)
6018    /// - `AUTUMN_FAULT_INJECTION__ENABLED` → `fault_injection.enabled` (`bool`)
6019    pub fn apply_env_overrides(&mut self) {
6020        self.apply_env_overrides_with_env(&OsEnv);
6021    }
6022
6023    /// Apply environment overrides using the provided env abstraction.
6024    pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
6025        self.apply_server_env_overrides_with_env(env);
6026        self.apply_deploy_env_overrides_with_env(env);
6027        self.apply_database_env_overrides_with_env(env);
6028        self.apply_log_env_overrides_with_env(env);
6029        self.apply_telemetry_env_overrides_with_env(env);
6030        self.apply_health_env_overrides_with_env(env);
6031        self.apply_cors_env_overrides_with_env(env);
6032        self.apply_session_env_overrides_with_env(env);
6033        self.apply_cache_env_overrides_with_env(env);
6034        self.apply_channels_env_overrides_with_env(env);
6035        self.apply_realtime_env_overrides_with_env(env);
6036        self.apply_jobs_env_overrides_with_env(env);
6037        self.apply_outbox_env_overrides_with_env(env);
6038        self.apply_scheduler_env_overrides_with_env(env);
6039        self.apply_retention_env_overrides_with_env(env);
6040        self.apply_role_env_overrides_with_env(env);
6041        self.apply_auth_env_overrides_with_env(env);
6042        self.apply_security_env_overrides_with_env(env);
6043        self.apply_bot_protection_env_overrides_with_env(env);
6044        self.apply_idempotency_env_overrides_with_env(env);
6045        self.apply_dev_env_overrides_with_env(env);
6046        self.apply_observability_env_overrides_with_env(env);
6047        self.apply_cost_env_overrides_with_env(env);
6048        self.apply_compression_env_overrides_with_env(env);
6049        self.apply_actuator_env_overrides_with_env(env);
6050        self.apply_metrics_env_overrides_with_env(env);
6051        #[cfg(feature = "reporting")]
6052        self.apply_reporting_env_overrides_with_env(env);
6053        #[cfg(feature = "reporting")]
6054        self.apply_failure_capture_env_overrides_with_env(env);
6055        #[cfg(feature = "storage")]
6056        self.apply_storage_env_overrides_with_env(env);
6057        self.apply_backup_env_overrides_with_env(env);
6058        self.apply_replication_env_overrides_with_env(env);
6059        #[cfg(feature = "mail")]
6060        self.apply_mail_env_overrides_with_env(env);
6061        #[cfg(feature = "maud")]
6062        self.apply_stories_env_overrides_with_env(env);
6063        self.apply_resilience_env_overrides_with_env(env);
6064        self.apply_time_zone_env_overrides_with_env(env);
6065        self.apply_alerts_env_overrides_with_env(env);
6066        self.apply_tenancy_env_overrides_with_env(env);
6067        self.apply_cluster_env_overrides_with_env(env);
6068        self.apply_push_env_overrides_with_env(env);
6069        self.apply_shadow_env_overrides_with_env(env);
6070        // Only the switch. `allow_in_production` stays in the file, so an
6071        // environment variable alone cannot turn on faults in prod.
6072        parse_env_bool(
6073            env,
6074            "AUTUMN_FAULT_INJECTION__ENABLED",
6075            &mut self.fault_injection.enabled,
6076        );
6077    }
6078
6079    /// Web Push (`[push]`) environment overrides.
6080    ///
6081    /// The private key is the reason this exists: it is a credential, so the
6082    /// guide and `PushConfig`'s own docs tell operators to supply it through
6083    /// `AUTUMN_PUSH__PRIVATE_KEY` rather than commit it. Overrides are applied
6084    /// only through the explicit per-section methods above, so without this one
6085    /// that documented deployment path silently leaves push unconfigured —
6086    /// every send failing `NotConfigured` and the public-key endpoint serving
6087    /// `503`, with the operator looking at a variable they did set.
6088    fn apply_push_env_overrides_with_env(&mut self, env: &dyn Env) {
6089        // Not `parse_env_option_secret`. That helper treats a blank value as "clear this
6090        // setting", which is right where unsetting via env is meaningful but wrong here,
6091        // and dangerously so: the commonest way `AUTUMN_PUSH__PRIVATE_KEY` ends up blank
6092        // is a secret that failed to interpolate. Clearing it would silently disable
6093        // push, sail through `validate_push`, and surface much later as a 503 and
6094        // `NotConfigured` on every send — and it would erase a good key from
6095        // `autumn.toml`. So a blank value is preserved, precisely so `load_vapid_key`
6096        // can reject it at boot with a message naming the environment variable.
6097        if let Ok(value) = env.var("AUTUMN_PUSH__PRIVATE_KEY") {
6098            self.push.private_key = Some(secrecy::SecretString::from(value));
6099        }
6100        parse_env_option_string(env, "AUTUMN_PUSH__PUBLIC_KEY", &mut self.push.public_key);
6101        parse_env_option_string(env, "AUTUMN_PUSH__SUBJECT", &mut self.push.subject);
6102        parse_env_option(env, "AUTUMN_PUSH__TTL_SECS", &mut self.push.ttl_secs);
6103    }
6104
6105    fn apply_shadow_env_overrides_with_env(&mut self, env: &dyn Env) {
6106        parse_env_bool(env, "AUTUMN_SHADOW__ENABLED", &mut self.shadow.enabled);
6107        parse_env_option_string(env, "AUTUMN_SHADOW__TARGET", &mut self.shadow.target);
6108        parse_env(
6109            env,
6110            "AUTUMN_SHADOW__SAMPLE_RATE",
6111            &mut self.shadow.sample_rate,
6112        );
6113        // `_non_empty`: an unfilled template (`AUTUMN_SHADOW__ROUTES=`) must not
6114        // become a one-element allowlist containing the empty pattern, which
6115        // matches no path and would silently mirror nothing (issue #1621's
6116        // failure shape).
6117        parse_env_csv_non_empty(env, "AUTUMN_SHADOW__ROUTES", &mut self.shadow.routes);
6118        parse_env(
6119            env,
6120            "AUTUMN_SHADOW__TIMEOUT_MS",
6121            &mut self.shadow.timeout_ms,
6122        );
6123        parse_env(
6124            env,
6125            "AUTUMN_SHADOW__MAX_IN_FLIGHT",
6126            &mut self.shadow.max_in_flight,
6127        );
6128        parse_env(
6129            env,
6130            "AUTUMN_SHADOW__MAX_BODY_BYTES",
6131            &mut self.shadow.max_body_bytes,
6132        );
6133        parse_env(
6134            env,
6135            "AUTUMN_SHADOW__MAX_RECORDS",
6136            &mut self.shadow.max_records,
6137        );
6138        parse_env(
6139            env,
6140            "AUTUMN_SHADOW__MAX_SAMPLE_BYTES",
6141            &mut self.shadow.max_sample_bytes,
6142        );
6143    }
6144
6145    fn apply_cluster_env_overrides_with_env(&mut self, env: &dyn Env) {
6146        parse_env_bool(env, "AUTUMN_CLUSTER__ENABLED", &mut self.cluster.enabled);
6147        parse_env_option_secret(env, "AUTUMN_CLUSTER__SECRET", &mut self.cluster.secret);
6148        parse_env_string(
6149            env,
6150            "AUTUMN_CLUSTER__CLUSTER_NAME",
6151            &mut self.cluster.cluster_name,
6152        );
6153        parse_env_string(
6154            env,
6155            "AUTUMN_CLUSTER__BIND_ADDR",
6156            &mut self.cluster.bind_addr,
6157        );
6158        parse_env_option_string(
6159            env,
6160            "AUTUMN_CLUSTER__ADVERTISE_ADDR",
6161            &mut self.cluster.advertise_addr,
6162        );
6163        parse_env_csv(
6164            env,
6165            "AUTUMN_CLUSTER__SEED_PEERS",
6166            &mut self.cluster.seed_peers,
6167        );
6168        parse_env_option_string(env, "AUTUMN_CLUSTER__NODE_ID", &mut self.cluster.node_id);
6169        parse_env(
6170            env,
6171            "AUTUMN_CLUSTER__PUSH_INTERVAL_MS",
6172            &mut self.cluster.push_interval_ms,
6173        );
6174        parse_env(
6175            env,
6176            "AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS",
6177            &mut self.cluster.suspicion_timeout_ms,
6178        );
6179    }
6180
6181    fn apply_tenancy_env_overrides_with_env(&mut self, env: &dyn Env) {
6182        parse_env_bool(env, "AUTUMN_TENANCY__ENABLED", &mut self.tenancy.enabled);
6183        parse_env_string(env, "AUTUMN_TENANCY__SOURCE", &mut self.tenancy.source);
6184        parse_env_string(
6185            env,
6186            "AUTUMN_TENANCY__HEADER_NAME",
6187            &mut self.tenancy.header_name,
6188        );
6189        parse_env_string(
6190            env,
6191            "AUTUMN_TENANCY__SESSION_KEY",
6192            &mut self.tenancy.session_key,
6193        );
6194        parse_env_string(
6195            env,
6196            "AUTUMN_TENANCY__JWT_CLAIM",
6197            &mut self.tenancy.jwt_claim,
6198        );
6199        parse_env_option_secret(
6200            env,
6201            "AUTUMN_TENANCY__JWT_SECRET",
6202            &mut self.tenancy.jwt_secret,
6203        );
6204        parse_env_option_string(
6205            env,
6206            "AUTUMN_TENANCY__JWT_ISSUER",
6207            &mut self.tenancy.jwt_issuer,
6208        );
6209        parse_env_option_string(
6210            env,
6211            "AUTUMN_TENANCY__JWT_AUDIENCE",
6212            &mut self.tenancy.jwt_audience,
6213        );
6214        parse_env_option_string(
6215            env,
6216            "AUTUMN_TENANCY__BASE_DOMAIN",
6217            &mut self.tenancy.base_domain,
6218        );
6219        parse_env_option_string(
6220            env,
6221            "AUTUMN_TENANCY__LOGIN_REDIRECT",
6222            &mut self.tenancy.login_redirect,
6223        );
6224        parse_env_csv(
6225            env,
6226            "AUTUMN_TENANCY__PUBLIC_PATHS",
6227            &mut self.tenancy.public_paths,
6228        );
6229        parse_env(
6230            env,
6231            "AUTUMN_TENANCY__QUOTA_BYTES",
6232            &mut self.tenancy.quota_bytes,
6233        );
6234        parse_env(
6235            env,
6236            "AUTUMN_TENANCY__MAX_CELLS",
6237            &mut self.tenancy.max_cells,
6238        );
6239        parse_env(
6240            env,
6241            "AUTUMN_TENANCY__IDLE_TTL_SECS",
6242            &mut self.tenancy.idle_ttl_secs,
6243        );
6244        parse_env(
6245            env,
6246            "AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS",
6247            &mut self.tenancy.max_concurrent_requests,
6248        );
6249        parse_env(
6250            env,
6251            "AUTUMN_TENANCY__MAX_DB_CONNECTIONS",
6252            &mut self.tenancy.max_db_connections,
6253        );
6254    }
6255
6256    fn apply_alerts_env_overrides_with_env(&mut self, env: &dyn Env) {
6257        parse_env_bool(env, "AUTUMN_ALERTS__ENABLED", &mut self.alerts.enabled);
6258        parse_env_option_string(env, "AUTUMN_ALERTS__EMAIL", &mut self.alerts.email);
6259        parse_env_option_string(
6260            env,
6261            "AUTUMN_ALERTS__WEBHOOK_URL",
6262            &mut self.alerts.webhook_url,
6263        );
6264        parse_env_option_string(
6265            env,
6266            "AUTUMN_ALERTS__WEBHOOK_SECRET",
6267            &mut self.alerts.webhook_secret,
6268        );
6269        parse_env_option_string(
6270            env,
6271            "AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
6272            &mut self.alerts.pagerduty_routing_key,
6273        );
6274        parse_env_option_string(
6275            env,
6276            "AUTUMN_ALERTS__PAGERDUTY_URL",
6277            &mut self.alerts.pagerduty_url,
6278        );
6279        parse_env_option_string(
6280            env,
6281            "AUTUMN_ALERTS__SLACK_WEBHOOK_URL",
6282            &mut self.alerts.slack_webhook_url,
6283        );
6284        parse_env_option_string(
6285            env,
6286            "AUTUMN_ALERTS__DISCORD_WEBHOOK_URL",
6287            &mut self.alerts.discord_webhook_url,
6288        );
6289        // Per-channel severity routing (`all` / `critical`). `AlertRouting`'s
6290        // `FromStr` accepts the same spellings as the TOML/serde path; an invalid
6291        // value is logged and ignored by `parse_env`, leaving the current value.
6292        parse_env(
6293            env,
6294            "AUTUMN_ALERTS__PAGERDUTY_SEVERITIES",
6295            &mut self.alerts.pagerduty_severities,
6296        );
6297        parse_env(
6298            env,
6299            "AUTUMN_ALERTS__SLACK_SEVERITIES",
6300            &mut self.alerts.slack_severities,
6301        );
6302        parse_env(
6303            env,
6304            "AUTUMN_ALERTS__DISCORD_SEVERITIES",
6305            &mut self.alerts.discord_severities,
6306        );
6307        parse_env_bool(
6308            env,
6309            "AUTUMN_ALERTS__CUSTOM_CHANNEL",
6310            &mut self.alerts.custom_channel,
6311        );
6312        parse_env(
6313            env,
6314            "AUTUMN_ALERTS__DEDUP_WINDOW_SECS",
6315            &mut self.alerts.dedup_window_secs,
6316        );
6317        parse_env(
6318            env,
6319            "AUTUMN_ALERTS__HEALTH_GRACE_SECS",
6320            &mut self.alerts.health_grace_secs,
6321        );
6322        parse_env(
6323            env,
6324            "AUTUMN_ALERTS__ERROR_RATE_THRESHOLD",
6325            &mut self.alerts.error_rate_threshold,
6326        );
6327        parse_env(
6328            env,
6329            "AUTUMN_ALERTS__ERROR_RATE_MIN_REQUESTS",
6330            &mut self.alerts.error_rate_min_requests,
6331        );
6332        parse_env(
6333            env,
6334            "AUTUMN_ALERTS__EVAL_INTERVAL_SECS",
6335            &mut self.alerts.eval_interval_secs,
6336        );
6337    }
6338
6339    fn apply_time_zone_env_overrides_with_env(&mut self, env: &dyn Env) {
6340        parse_env_string(
6341            env,
6342            "AUTUMN_TIME_ZONE__IDENTIFIER",
6343            &mut self.time_zone.identifier,
6344        );
6345    }
6346
6347    #[cfg(feature = "reporting")]
6348    fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
6349        parse_env_bool(
6350            env,
6351            "AUTUMN_REPORTING__ENABLED",
6352            &mut self.reporting.enabled,
6353        );
6354        parse_env(
6355            env,
6356            "AUTUMN_REPORTING__SAMPLE_RATE",
6357            &mut self.reporting.sample_rate,
6358        );
6359    }
6360
6361    #[cfg(feature = "reporting")]
6362    fn apply_failure_capture_env_overrides_with_env(&mut self, env: &dyn Env) {
6363        parse_env_bool(
6364            env,
6365            "AUTUMN_FAILURE_CAPTURE__ENABLED",
6366            &mut self.failure_capture.enabled,
6367        );
6368        parse_env_string(
6369            env,
6370            "AUTUMN_FAILURE_CAPTURE__DIR",
6371            &mut self.failure_capture.dir,
6372        );
6373        parse_env(
6374            env,
6375            "AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES",
6376            &mut self.failure_capture.max_body_bytes,
6377        );
6378        parse_env(
6379            env,
6380            "AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES",
6381            &mut self.failure_capture.max_capsule_bytes,
6382        );
6383        parse_env(
6384            env,
6385            "AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES",
6386            &mut self.failure_capture.max_capsules,
6387        );
6388    }
6389
6390    fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
6391        parse_env_string(
6392            env,
6393            "AUTUMN_DEV__INSPECTOR_PATH",
6394            &mut self.dev.inspector_path,
6395        );
6396        parse_env(
6397            env,
6398            "AUTUMN_DEV__INSPECTOR_CAPACITY",
6399            &mut self.dev.inspector_capacity,
6400        );
6401        parse_env(
6402            env,
6403            "AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
6404            &mut self.dev.inspector_n_plus_one_threshold,
6405        );
6406    }
6407
6408    fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
6409        parse_env_bool(
6410            env,
6411            "AUTUMN_COMPRESSION__ENABLED",
6412            &mut self.compression.enabled,
6413        );
6414    }
6415
6416    fn apply_cost_env_overrides_with_env(&mut self, env: &dyn Env) {
6417        parse_env_bool(env, "AUTUMN_COST__ENABLED", &mut self.cost.enabled);
6418        parse_env_option(
6419            env,
6420            "AUTUMN_COST__DEFER_THRESHOLD",
6421            &mut self.cost.defer_threshold,
6422        );
6423        parse_env(
6424            env,
6425            "AUTUMN_COST__DEFER_RECHECK_SECS",
6426            &mut self.cost.defer_recheck_secs,
6427        );
6428        parse_env(env, "AUTUMN_COST__MAX_TENANTS", &mut self.cost.max_tenants);
6429        parse_env(
6430            env,
6431            "AUTUMN_COST__SIGNAL_REFRESH_SECS",
6432            &mut self.cost.signal_refresh_secs,
6433        );
6434    }
6435
6436    fn apply_observability_env_overrides_with_env(&mut self, env: &dyn Env) {
6437        parse_env_option_bool(
6438            env,
6439            "AUTUMN_OBSERVABILITY__SERVER_TIMING",
6440            &mut self.observability.server_timing,
6441        );
6442    }
6443
6444    #[cfg(feature = "maud")]
6445    fn apply_stories_env_overrides_with_env(&mut self, env: &dyn Env) {
6446        parse_env_bool(env, "AUTUMN_STORIES__ENABLED", &mut self.stories.enabled);
6447    }
6448
6449    fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
6450        parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
6451        parse_env_bool(
6452            env,
6453            "AUTUMN_ACTUATOR__SENSITIVE",
6454            &mut self.actuator.sensitive,
6455        );
6456        // Security-sensitive: operators disable the Prometheus scrape endpoint
6457        // with AUTUMN_ACTUATOR__PROMETHEUS=false; the override must be honored
6458        // so the endpoint is not left exposed against the operator's intent.
6459        parse_env_bool(
6460            env,
6461            "AUTUMN_ACTUATOR__PROMETHEUS",
6462            &mut self.actuator.prometheus,
6463        );
6464    }
6465
6466    fn apply_metrics_env_overrides_with_env(&mut self, env: &dyn Env) {
6467        parse_env(
6468            env,
6469            "AUTUMN_METRICS__MAX_SERIES_PER_METRIC",
6470            &mut self.metrics.max_series_per_metric,
6471        );
6472        parse_env(
6473            env,
6474            "AUTUMN_METRICS__MAX_INSTRUMENTS",
6475            &mut self.metrics.max_instruments,
6476        );
6477        parse_env(
6478            env,
6479            "AUTUMN_METRICS__MAX_LABELS_PER_SERIES",
6480            &mut self.metrics.max_labels_per_series,
6481        );
6482    }
6483
6484    fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
6485        parse_env_option_bool(
6486            env,
6487            "AUTUMN_IDEMPOTENCY__ENABLED",
6488            &mut self.idempotency.enabled,
6489        );
6490        if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
6491            match IdempotencyBackend::from_env_value(&val) {
6492                Some(backend) => self.idempotency.backend = backend,
6493                None => eprintln!(
6494                    "Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
6495                ),
6496            }
6497        }
6498        parse_env(
6499            env,
6500            "AUTUMN_IDEMPOTENCY__TTL_SECS",
6501            &mut self.idempotency.ttl_secs,
6502        );
6503        parse_env(
6504            env,
6505            "AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
6506            &mut self.idempotency.in_flight_ttl_secs,
6507        );
6508        parse_env_bool(
6509            env,
6510            "AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
6511            &mut self.idempotency.allow_memory_in_production,
6512        );
6513        parse_env_string(
6514            env,
6515            "AUTUMN_IDEMPOTENCY__REDIS__URL",
6516            self.idempotency.redis.url.get_or_insert_with(String::new),
6517        );
6518        parse_env_string(
6519            env,
6520            "AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
6521            &mut self.idempotency.redis.key_prefix,
6522        );
6523    }
6524
6525    /// `[server.admission]` env overrides (issue #3068).
6526    fn apply_admission_env_overrides_with_env(&mut self, env: &dyn Env) {
6527        let admission = &mut self.server.admission;
6528        parse_env(env, "AUTUMN_SERVER__ADMISSION__MODE", &mut admission.mode);
6529        parse_env(
6530            env,
6531            "AUTUMN_SERVER__ADMISSION__ALGORITHM",
6532            &mut admission.algorithm,
6533        );
6534        parse_env(
6535            env,
6536            "AUTUMN_SERVER__ADMISSION__MIN_LIMIT",
6537            &mut admission.min_limit,
6538        );
6539        parse_env_option(
6540            env,
6541            "AUTUMN_SERVER__ADMISSION__MAX_LIMIT",
6542            &mut admission.max_limit,
6543        );
6544        parse_env(
6545            env,
6546            "AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT",
6547            &mut admission.initial_limit,
6548        );
6549        parse_env(
6550            env,
6551            "AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS",
6552            &mut admission.latency_threshold_ms,
6553        );
6554        parse_env_bool(
6555            env,
6556            "AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER",
6557            &mut admission.trust_criticality_header,
6558        );
6559        parse_env(
6560            env,
6561            "AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT",
6562            &mut admission.partitions.default,
6563        );
6564        parse_env(
6565            env,
6566            "AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE",
6567            &mut admission.partitions.sheddable,
6568        );
6569    }
6570
6571    /// `[server.http]` connection limits (issue #3065).
6572    fn apply_server_http_env_overrides_with_env(&mut self, env: &dyn Env) {
6573        let http = &mut self.server.http;
6574        parse_env_option(
6575            env,
6576            "AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS",
6577            &mut http.header_read_timeout_ms,
6578        );
6579        parse_env_option(
6580            env,
6581            "AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS",
6582            &mut http.keep_alive_timeout_ms,
6583        );
6584        parse_env_option(
6585            env,
6586            "AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES",
6587            &mut http.max_header_bytes,
6588        );
6589        parse_env_option(
6590            env,
6591            "AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS",
6592            &mut http.http2_max_concurrent_streams,
6593        );
6594        parse_env_option(
6595            env,
6596            "AUTUMN_SERVER__HTTP__MAX_CONNECTIONS",
6597            &mut http.max_connections,
6598        );
6599    }
6600
6601    fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
6602        parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
6603        self.apply_server_http_env_overrides_with_env(env);
6604        parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
6605        parse_env(
6606            env,
6607            "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
6608            &mut self.server.shutdown_timeout_secs,
6609        );
6610        parse_env(
6611            env,
6612            "AUTUMN_SERVER__PRESTOP_GRACE_SECS",
6613            &mut self.server.prestop_grace_secs,
6614        );
6615        parse_env(
6616            env,
6617            "AUTUMN_SERVER__UPGRADE__ENABLED",
6618            &mut self.server.upgrade.enabled,
6619        );
6620        parse_env(
6621            env,
6622            "AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS",
6623            &mut self.server.upgrade.ready_timeout_secs,
6624        );
6625        parse_env_option(
6626            env,
6627            "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
6628            &mut self.server.timeouts.request_timeout_ms,
6629        );
6630        parse_env_bool(
6631            env,
6632            "AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER",
6633            &mut self.server.timeouts.accept_deadline_header,
6634        );
6635        parse_env_option_string(
6636            env,
6637            "AUTUMN_SERVER__UNIX_SOCKET",
6638            &mut self.server.unix_socket,
6639        );
6640        parse_env_option(
6641            env,
6642            "AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS",
6643            &mut self.server.max_concurrent_requests,
6644        );
6645        parse_env_bool(
6646            env,
6647            "AUTUMN_SERVER__STRICT_CONFIG",
6648            &mut self.server.strict_config,
6649        );
6650        parse_env_option_string(
6651            env,
6652            "AUTUMN_SERVER__CAPACITY_CONTRACT",
6653            &mut self.server.capacity_contract,
6654        );
6655        self.apply_admission_env_overrides_with_env(env);
6656
6657        // `[server.tls]` is a nested optional. Materialize it from the
6658        // environment when any of its keys are set (seeding an empty struct if
6659        // the TOML section was absent), so a fully env-driven deployment can
6660        // enable direct HTTPS without an `autumn.toml` section. A partially
6661        // specified pair (e.g. only the cert) leaves the other path empty and
6662        // is caught by the startup fail-fast validation.
6663        let tls_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
6664        let tls_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
6665        let tls_reload = env.var("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS").ok();
6666        let tls_handshake = env.var("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS").ok();
6667        if tls_cert.is_some()
6668            || tls_key.is_some()
6669            || tls_reload.is_some()
6670            || tls_handshake.is_some()
6671        {
6672            let tls = self.server.tls.get_or_insert_with(TlsConfig::empty_for_env);
6673            if let Some(cert) = tls_cert {
6674                tls.cert_path = Some(PathBuf::from(cert));
6675            }
6676            if let Some(key) = tls_key {
6677                tls.key_path = Some(PathBuf::from(key));
6678            }
6679            if let Some(reload) = tls_reload.and_then(|v| v.trim().parse::<u64>().ok()) {
6680                tls.reload_interval_secs = reload;
6681            }
6682            if let Some(handshake) = tls_handshake.and_then(|v| v.trim().parse::<u64>().ok()) {
6683                tls.handshake_timeout_secs = handshake;
6684            }
6685        }
6686    }
6687
6688    fn apply_deploy_env_overrides_with_env(&mut self, env: &dyn Env) {
6689        apply_deploy_env_overrides(&mut self.deploy, env);
6690    }
6691
6692    /// `database.replica_max_lag_ms` and `database.warn_on_pooler` (issue #3065).
6693    fn apply_replica_lag_and_pooler_env_overrides(&mut self, env: &dyn Env) {
6694        parse_env_option(
6695            env,
6696            "AUTUMN_DATABASE__REPLICA_MAX_LAG_MS",
6697            &mut self.database.replica_max_lag_ms,
6698        );
6699        parse_env_bool(
6700            env,
6701            "AUTUMN_DATABASE__WARN_ON_POOLER",
6702            &mut self.database.warn_on_pooler,
6703        );
6704    }
6705
6706    fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
6707        if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
6708            self.database.url = Some(val);
6709            self.database.primary_url = None;
6710        }
6711        parse_env_option_string(
6712            env,
6713            "AUTUMN_DATABASE__PRIMARY_URL",
6714            &mut self.database.primary_url,
6715        );
6716        parse_env_option_string(
6717            env,
6718            "AUTUMN_DATABASE__REPLICA_URL",
6719            &mut self.database.replica_url,
6720        );
6721        parse_env(
6722            env,
6723            "AUTUMN_DATABASE__POOL_SIZE",
6724            &mut self.database.pool_size,
6725        );
6726        parse_env_option(
6727            env,
6728            "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
6729            &mut self.database.primary_pool_size,
6730        );
6731        parse_env_option(
6732            env,
6733            "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
6734            &mut self.database.replica_pool_size,
6735        );
6736        parse_env(
6737            env,
6738            "AUTUMN_DATABASE__REPLICA_FALLBACK",
6739            &mut self.database.replica_fallback,
6740        );
6741        parse_env(
6742            env,
6743            "AUTUMN_DATABASE__READ_YOUR_WRITES",
6744            &mut self.database.read_your_writes,
6745        );
6746        parse_env(
6747            env,
6748            "AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS",
6749            &mut self.database.pin_after_write_secs,
6750        );
6751        self.apply_replica_lag_and_pooler_env_overrides(env);
6752        parse_env(
6753            env,
6754            "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
6755            &mut self.database.connect_timeout_secs,
6756        );
6757        parse_env(
6758            env,
6759            "AUTUMN_DATABASE__STARTUP_WAIT_SECS",
6760            &mut self.database.startup_wait_secs,
6761        );
6762        parse_env_option_bool(
6763            env,
6764            "AUTUMN_DATABASE__AUTO_MIGRATE",
6765            &mut self.database.auto_migrate,
6766        );
6767        parse_env_bool(
6768            env,
6769            "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
6770            &mut self.database.auto_migrate_in_production,
6771        );
6772        parse_env_bool(
6773            env,
6774            "AUTUMN_DATABASE__DIRECTORY_SHARD_ROUTER",
6775            &mut self.database.directory_shard_router,
6776        );
6777        parse_env_option_duration(
6778            env,
6779            "AUTUMN_DATABASE__STATEMENT_TIMEOUT",
6780            &mut self.database.statement_timeout,
6781        );
6782        parse_env_option_duration(
6783            env,
6784            "AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT",
6785            &mut self.database.idle_in_transaction_timeout,
6786        );
6787        // An empty value is ignored here, as `autumn migrate` ignores it.
6788        let mut lock_timeout = Some(self.database.migration_lock_timeout);
6789        parse_env_option_duration(
6790            env,
6791            "AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT",
6792            &mut lock_timeout,
6793        );
6794        if let Some(timeout) = lock_timeout {
6795            self.database.migration_lock_timeout = timeout;
6796        }
6797        parse_env(
6798            env,
6799            "AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES",
6800            &mut self.database.migration_lock_retries,
6801        );
6802        self.apply_shard_env_overrides(env);
6803        self.apply_fleet_env_overrides(env);
6804    }
6805
6806    /// Apply `AUTUMN_DATABASE__FLEET__*` overrides (ADR 0019).
6807    ///
6808    /// With no `[database.fleet]` in TOML, setting both `..._MODE` and
6809    /// `..._ROOT` creates one; the other keys then adjust it.
6810    fn apply_fleet_env_overrides(&mut self, env: &dyn Env) {
6811        if self.database.fleet.is_none() {
6812            let mode = env.var("AUTUMN_DATABASE__FLEET__MODE").ok();
6813            let root = env.var("AUTUMN_DATABASE__FLEET__ROOT").ok();
6814            let (Some(mode), Some(root)) = (mode, root) else {
6815                return;
6816            };
6817            let Ok(mode) = mode.parse::<crate::fleet_layout::FleetMode>() else {
6818                eprintln!("Warning: AUTUMN_DATABASE__FLEET__MODE={mode:?} is not valid, ignoring");
6819                return;
6820            };
6821            self.database.fleet = Some(DatabaseFleetConfig {
6822                mode,
6823                root,
6824                path: None,
6825                max_open: default_fleet_max_open(),
6826                pool_size: default_fleet_pool_size(),
6827                create_on_demand: None,
6828                idle_close_secs: default_fleet_idle_close_secs(),
6829                restore_missing: false,
6830                ownership: None,
6831            });
6832        }
6833        let Some(fleet) = self.database.fleet.as_mut() else {
6834            return;
6835        };
6836        parse_env(env, "AUTUMN_DATABASE__FLEET__MODE", &mut fleet.mode);
6837        parse_env(env, "AUTUMN_DATABASE__FLEET__ROOT", &mut fleet.root);
6838        parse_env_option_string(env, "AUTUMN_DATABASE__FLEET__PATH", &mut fleet.path);
6839        parse_env(env, "AUTUMN_DATABASE__FLEET__MAX_OPEN", &mut fleet.max_open);
6840        parse_env(
6841            env,
6842            "AUTUMN_DATABASE__FLEET__POOL_SIZE",
6843            &mut fleet.pool_size,
6844        );
6845        parse_env_option_bool(
6846            env,
6847            "AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND",
6848            &mut fleet.create_on_demand,
6849        );
6850        parse_env(
6851            env,
6852            "AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS",
6853            &mut fleet.idle_close_secs,
6854        );
6855        parse_env_bool(
6856            env,
6857            "AUTUMN_DATABASE__FLEET__RESTORE_MISSING",
6858            &mut fleet.restore_missing,
6859        );
6860        // Setting `..._OWNER` creates the ownership section.
6861        if fleet.ownership.is_none() {
6862            if let Ok(owner) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER") {
6863                fleet.ownership = Some(FleetOwnershipConfig {
6864                    owner,
6865                    slots: Vec::new(),
6866                    lease_secs: default_fleet_lease_secs(),
6867                });
6868            } else if env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS").is_ok()
6869                || env
6870                    .var("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS")
6871                    .is_ok()
6872            {
6873                eprintln!(
6874                    "Warning: AUTUMN_DATABASE__FLEET__OWNERSHIP__* is set without \
6875                     AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER or a [database.fleet.ownership] \
6876                     section, ignoring"
6877                );
6878            }
6879        }
6880        let Some(ownership) = fleet.ownership.as_mut() else {
6881            return;
6882        };
6883        parse_env_string(
6884            env,
6885            "AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER",
6886            &mut ownership.owner,
6887        );
6888        // Comma-separated `N` and `A-B` entries, e.g. "0-8191,9000".
6889        if let Ok(slots) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS") {
6890            ownership.slots = slots
6891                .split(',')
6892                .map(str::trim)
6893                .filter(|token| !token.is_empty())
6894                .map(|token| SlotSpec::Range(token.to_owned()))
6895                .collect();
6896        }
6897        parse_env(
6898            env,
6899            "AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS",
6900            &mut ownership.lease_secs,
6901        );
6902    }
6903
6904    /// Apply `AUTUMN_DATABASE__SHARDS__{i}__*` environment overrides.
6905    ///
6906    /// The [`Env`] abstraction can only probe known keys, so shard entries
6907    /// are addressed positionally: index `i` corresponds to the i-th
6908    /// `[[database.shards]]` entry in declaration order. Existing entries
6909    /// can have individual fields overridden; a brand-new entry is appended
6910    /// when both `__NAME` and `__PRIMARY_URL` are provided for the next
6911    /// free index. Probing stops at the first index that neither exists in
6912    /// TOML nor defines a complete new shard (bounded at 64).
6913    fn apply_shard_env_overrides(&mut self, env: &dyn Env) {
6914        const MAX_ENV_SHARDS: usize = 64;
6915        for i in 0..MAX_ENV_SHARDS {
6916            let key = |field: &str| format!("AUTUMN_DATABASE__SHARDS__{i}__{field}");
6917            if i >= self.database.shards.len() {
6918                let (Ok(name), Ok(primary_url)) =
6919                    (env.var(&key("NAME")), env.var(&key("PRIMARY_URL")))
6920                else {
6921                    break;
6922                };
6923                self.database.shards.push(ShardConfig {
6924                    name,
6925                    primary_url,
6926                    slots: None,
6927                    replica_url: None,
6928                    primary_pool_size: None,
6929                    replica_pool_size: None,
6930                    replica_fallback: None,
6931                });
6932            }
6933            let shard = &mut self.database.shards[i];
6934            parse_env_string(env, &key("NAME"), &mut shard.name);
6935            parse_env_string(env, &key("PRIMARY_URL"), &mut shard.primary_url);
6936            // Comma-separated indices and/or "A-B" ranges, e.g. "0-15,40,62-63".
6937            if let Ok(val) = env.var(&key("SLOTS")) {
6938                shard.slots = Some(
6939                    val.split(',')
6940                        .map(|token| SlotSpec::Range(token.trim().to_owned()))
6941                        .collect(),
6942                );
6943            }
6944            parse_env_option_string(env, &key("REPLICA_URL"), &mut shard.replica_url);
6945            parse_env_option(env, &key("PRIMARY_POOL_SIZE"), &mut shard.primary_pool_size);
6946            parse_env_option(env, &key("REPLICA_POOL_SIZE"), &mut shard.replica_pool_size);
6947            parse_env_option(env, &key("REPLICA_FALLBACK"), &mut shard.replica_fallback);
6948        }
6949    }
6950
6951    fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
6952        parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
6953        parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
6954        parse_env_csv(
6955            env,
6956            "AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
6957            &mut self.log.access_log_exclude,
6958        );
6959        if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
6960            match val.as_str() {
6961                "Auto" => self.log.format = LogFormat::Auto,
6962                "Pretty" => self.log.format = LogFormat::Pretty,
6963                "Json" => self.log.format = LogFormat::Json,
6964                _ => eprintln!(
6965                    "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
6966                     (expected Auto, Pretty, or Json), ignoring"
6967                ),
6968            }
6969        }
6970    }
6971
6972    fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
6973        // ── Health ──────────────────────────────────────────────
6974        parse_env_bool(
6975            env,
6976            "AUTUMN_TELEMETRY__ENABLED",
6977            &mut self.telemetry.enabled,
6978        );
6979        parse_env_string(
6980            env,
6981            "AUTUMN_TELEMETRY__SERVICE_NAME",
6982            &mut self.telemetry.service_name,
6983        );
6984        parse_env_option_string(
6985            env,
6986            "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
6987            &mut self.telemetry.service_namespace,
6988        );
6989        parse_env_string(
6990            env,
6991            "AUTUMN_TELEMETRY__SERVICE_VERSION",
6992            &mut self.telemetry.service_version,
6993        );
6994        parse_env_string(
6995            env,
6996            "AUTUMN_TELEMETRY__ENVIRONMENT",
6997            &mut self.telemetry.environment,
6998        );
6999        parse_env_option_string(
7000            env,
7001            "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
7002            &mut self.telemetry.otlp_endpoint,
7003        );
7004        if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
7005            match TelemetryProtocol::from_env_value(&val) {
7006                Some(protocol) => self.telemetry.protocol = protocol,
7007                None => eprintln!(
7008                    "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
7009                     (expected Grpc or HttpProtobuf), ignoring"
7010                ),
7011            }
7012        }
7013        parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
7014        if let Ok(val) = env.var("AUTUMN_TELEMETRY__SAMPLE_RATIO") {
7015            match val.trim().parse::<f64>() {
7016                Ok(ratio) => self.telemetry.sample_ratio = ratio,
7017                Err(_) => eprintln!(
7018                    "Warning: AUTUMN_TELEMETRY__SAMPLE_RATIO={val:?} is not a number, ignoring"
7019                ),
7020            }
7021        }
7022    }
7023
7024    fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
7025        parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
7026        parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
7027        parse_env_string(
7028            env,
7029            "AUTUMN_HEALTH__READY_PATH",
7030            &mut self.health.ready_path,
7031        );
7032        parse_env_string(
7033            env,
7034            "AUTUMN_HEALTH__STARTUP_PATH",
7035            &mut self.health.startup_path,
7036        );
7037        parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
7038        parse_env_bool(env, "AUTUMN_HEALTH__ENABLED", &mut self.health.enabled);
7039        parse_env(
7040            env,
7041            "AUTUMN_HEALTH__CACHE_TTL_MS",
7042            &mut self.health.cache_ttl_ms,
7043        );
7044        parse_env(
7045            env,
7046            "AUTUMN_HEALTH__PING_TIMEOUT_MS",
7047            &mut self.health.ping_timeout_ms,
7048        );
7049        parse_env_bool(
7050            env,
7051            "AUTUMN_HEALTH__REDIS_READINESS",
7052            &mut self.health.redis_readiness,
7053        );
7054        parse_env_bool(
7055            env,
7056            "AUTUMN_HEALTH__DB_READINESS",
7057            &mut self.health.db_readiness,
7058        );
7059    }
7060
7061    fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
7062        parse_env_csv(
7063            env,
7064            "AUTUMN_CORS__ALLOWED_ORIGINS",
7065            &mut self.cors.allowed_origins,
7066        );
7067        parse_env_csv(
7068            env,
7069            "AUTUMN_CORS__ALLOWED_METHODS",
7070            &mut self.cors.allowed_methods,
7071        );
7072        parse_env_csv(
7073            env,
7074            "AUTUMN_CORS__ALLOWED_HEADERS",
7075            &mut self.cors.allowed_headers,
7076        );
7077        parse_env_bool(
7078            env,
7079            "AUTUMN_CORS__ALLOW_CREDENTIALS",
7080            &mut self.cors.allow_credentials,
7081        );
7082        parse_env(
7083            env,
7084            "AUTUMN_CORS__MAX_AGE_SECS",
7085            &mut self.cors.max_age_secs,
7086        );
7087    }
7088
7089    fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
7090        parse_env_string(
7091            env,
7092            "AUTUMN_SESSION__COOKIE_NAME",
7093            &mut self.session.cookie_name,
7094        );
7095        if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
7096            match crate::session::SessionBackend::from_env_value(&val) {
7097                Some(backend) => self.session.backend = backend,
7098                None => eprintln!(
7099                    "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
7100                     (expected memory or redis), ignoring"
7101                ),
7102            }
7103        }
7104        parse_env(
7105            env,
7106            "AUTUMN_SESSION__MAX_AGE_SECS",
7107            &mut self.session.max_age_secs,
7108        );
7109        parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
7110        parse_env_string(
7111            env,
7112            "AUTUMN_SESSION__SAME_SITE",
7113            &mut self.session.same_site,
7114        );
7115        parse_env_bool(
7116            env,
7117            "AUTUMN_SESSION__HTTP_ONLY",
7118            &mut self.session.http_only,
7119        );
7120        parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
7121        parse_env_bool(
7122            env,
7123            "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
7124            &mut self.session.allow_memory_in_production,
7125        );
7126        parse_env_option_string(
7127            env,
7128            "AUTUMN_SESSION__REDIS__URL",
7129            &mut self.session.redis.url,
7130        );
7131        parse_env_string(
7132            env,
7133            "AUTUMN_SESSION__REDIS__KEY_PREFIX",
7134            &mut self.session.redis.key_prefix,
7135        );
7136    }
7137
7138    fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
7139        if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
7140            match CacheBackend::from_env_value(&val) {
7141                Some(backend) => self.cache.backend = backend,
7142                None => eprintln!(
7143                    "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
7144                     (expected memory or redis), ignoring"
7145                ),
7146            }
7147        }
7148        parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
7149        parse_env_string(
7150            env,
7151            "AUTUMN_CACHE__REDIS__KEY_PREFIX",
7152            &mut self.cache.redis.key_prefix,
7153        );
7154    }
7155
7156    fn apply_realtime_env_overrides_with_env(&mut self, env: &dyn Env) {
7157        let realtime = &mut self.realtime;
7158        parse_env_option(
7159            env,
7160            "AUTUMN_REALTIME__MAX_CONNECTIONS",
7161            &mut realtime.max_connections,
7162        );
7163        parse_env_option(
7164            env,
7165            "AUTUMN_REALTIME__MAX_MESSAGE_BYTES",
7166            &mut realtime.max_message_bytes,
7167        );
7168        parse_env_option(
7169            env,
7170            "AUTUMN_REALTIME__PING_INTERVAL_MS",
7171            &mut realtime.ping_interval_ms,
7172        );
7173        parse_env_option(
7174            env,
7175            "AUTUMN_REALTIME__IDLE_TIMEOUT_MS",
7176            &mut realtime.idle_timeout_ms,
7177        );
7178    }
7179
7180    fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
7181        if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
7182            match ChannelBackend::from_env_value(&val) {
7183                Some(backend) => self.channels.backend = backend,
7184                None => eprintln!(
7185                    "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
7186                     (expected in_process or redis), ignoring"
7187                ),
7188            }
7189        }
7190        parse_env(
7191            env,
7192            "AUTUMN_CHANNELS__CAPACITY",
7193            &mut self.channels.capacity,
7194        );
7195        parse_env(
7196            env,
7197            "AUTUMN_CHANNELS__REPLAY_BUFFER",
7198            &mut self.channels.replay_buffer,
7199        );
7200        parse_env_option_string(
7201            env,
7202            "AUTUMN_CHANNELS__REDIS__URL",
7203            &mut self.channels.redis.url,
7204        );
7205        parse_env_string(
7206            env,
7207            "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
7208            &mut self.channels.redis.key_prefix,
7209        );
7210    }
7211
7212    fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
7213        parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
7214        parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
7215        if let Ok(val) = env.var("AUTUMN_JOBS__PIN") {
7216            self.jobs.pin = val
7217                .split(',')
7218                .map(str::trim)
7219                .filter(|s| !s.is_empty())
7220                .map(str::to_owned)
7221                .collect();
7222        }
7223        parse_env(
7224            env,
7225            "AUTUMN_JOBS__MAX_ATTEMPTS",
7226            &mut self.jobs.max_attempts,
7227        );
7228        parse_env(
7229            env,
7230            "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
7231            &mut self.jobs.initial_backoff_ms,
7232        );
7233        parse_env(
7234            env,
7235            "AUTUMN_JOBS__MAX_BACKOFF_MS",
7236            &mut self.jobs.max_backoff_ms,
7237        );
7238        parse_env(
7239            env,
7240            "AUTUMN_JOBS__DEFAULT_TIMEOUT_MS",
7241            &mut self.jobs.default_timeout_ms,
7242        );
7243        parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
7244        parse_env_string(
7245            env,
7246            "AUTUMN_JOBS__REDIS__KEY_PREFIX",
7247            &mut self.jobs.redis.key_prefix,
7248        );
7249        parse_env(
7250            env,
7251            "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
7252            &mut self.jobs.redis.visibility_timeout_ms,
7253        );
7254        parse_env(
7255            env,
7256            "AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT",
7257            &mut self.jobs.redis.dead_letter_limit,
7258        );
7259        parse_env(
7260            env,
7261            "AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
7262            &mut self.jobs.postgres.visibility_timeout_ms,
7263        );
7264        parse_env_bool(
7265            env,
7266            "AUTUMN_JOBS__POSTGRES__SHARD_LOCAL",
7267            &mut self.jobs.postgres.shard_local,
7268        );
7269        parse_env(
7270            env,
7271            "AUTUMN_JOBS__TENANTS__MAX_CONCURRENT",
7272            &mut self.jobs.tenants.max_concurrent,
7273        );
7274        parse_env(
7275            env,
7276            "AUTUMN_JOBS__TENANTS__LANES",
7277            &mut self.jobs.tenants.lanes,
7278        );
7279        parse_env(
7280            env,
7281            "AUTUMN_JOBS__TENANTS__LANES_PER_TENANT",
7282            &mut self.jobs.tenants.lanes_per_tenant,
7283        );
7284        parse_env(
7285            env,
7286            "AUTUMN_JOBS__SQLITE__VISIBILITY_TIMEOUT_MS",
7287            &mut self.jobs.sqlite.visibility_timeout_ms,
7288        );
7289        parse_env(
7290            env,
7291            "AUTUMN_JOBS__SQLITE__POLL_INTERVAL_MS",
7292            &mut self.jobs.sqlite.poll_interval_ms,
7293        );
7294        parse_env(
7295            env,
7296            "AUTUMN_JOBS__TRACKING__TTL_SECS",
7297            &mut self.jobs.tracking.ttl_secs,
7298        );
7299        parse_env_bool(
7300            env,
7301            "AUTUMN_JOBS__TRACKING__ROUTE_ENABLED",
7302            &mut self.jobs.tracking.route_enabled,
7303        );
7304    }
7305
7306    fn apply_outbox_env_overrides_with_env(&mut self, env: &dyn Env) {
7307        parse_env_bool(env, "AUTUMN_OUTBOX__ENABLED", &mut self.outbox.enabled);
7308        parse_env(
7309            env,
7310            "AUTUMN_OUTBOX__POLL_INTERVAL_MS",
7311            &mut self.outbox.poll_interval_ms,
7312        );
7313        parse_env(
7314            env,
7315            "AUTUMN_OUTBOX__BATCH_SIZE",
7316            &mut self.outbox.batch_size,
7317        );
7318        parse_env(
7319            env,
7320            "AUTUMN_OUTBOX__MAX_ATTEMPTS",
7321            &mut self.outbox.max_attempts,
7322        );
7323        parse_env(
7324            env,
7325            "AUTUMN_OUTBOX__INITIAL_BACKOFF_MS",
7326            &mut self.outbox.initial_backoff_ms,
7327        );
7328        parse_env(
7329            env,
7330            "AUTUMN_OUTBOX__MAX_BACKOFF_MS",
7331            &mut self.outbox.max_backoff_ms,
7332        );
7333        parse_env(env, "AUTUMN_OUTBOX__LEASE_MS", &mut self.outbox.lease_ms);
7334        parse_env(
7335            env,
7336            "AUTUMN_OUTBOX__RETENTION_MS",
7337            &mut self.outbox.retention_ms,
7338        );
7339    }
7340
7341    fn apply_role_env_overrides_with_env(&mut self, env: &dyn Env) {
7342        if let Ok(val) = env.var("AUTUMN_ROLE") {
7343            match ProcessRole::from_env_value(&val) {
7344                Some(role) => self.role = role,
7345                None => eprintln!(
7346                    "Warning: AUTUMN_ROLE={val:?} is not valid \
7347                     (expected combined, web, or worker), ignoring"
7348                ),
7349            }
7350        }
7351    }
7352
7353    /// `AUTUMN_RETENTION__*` overrides for the unified retention policy
7354    /// (issue #1605).
7355    ///
7356    /// Each window uses [`parse_env_option_string`], so an empty value is the
7357    /// documented way to *clear* a window `autumn.toml` declared — restoring
7358    /// today's behavior for that one dataset without editing the file.
7359    fn apply_retention_env_overrides_with_env(&mut self, env: &dyn Env) {
7360        parse_env_string(
7361            env,
7362            "AUTUMN_RETENTION__SWEEP_INTERVAL",
7363            &mut self.retention.sweep_interval,
7364        );
7365        parse_env_option_string(
7366            env,
7367            "AUTUMN_RETENTION__JOB_HISTORY",
7368            &mut self.retention.job_history,
7369        );
7370        parse_env_option_string(
7371            env,
7372            "AUTUMN_RETENTION__COMMIT_HOOKS",
7373            &mut self.retention.commit_hooks,
7374        );
7375        parse_env_option_string(
7376            env,
7377            "AUTUMN_RETENTION__JOB_TRACKING",
7378            &mut self.retention.job_tracking,
7379        );
7380        parse_env_option_string(
7381            env,
7382            "AUTUMN_RETENTION__IDEMPOTENCY",
7383            &mut self.retention.idempotency,
7384        );
7385        parse_env_option_string(
7386            env,
7387            "AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS",
7388            &mut self.retention.experiment_assignments,
7389        );
7390        parse_env_option_string(
7391            env,
7392            "AUTUMN_RETENTION__WEBHOOK_REPLAY",
7393            &mut self.retention.webhook_replay,
7394        );
7395        parse_env_option_string(
7396            env,
7397            "AUTUMN_RETENTION__SESSIONS",
7398            &mut self.retention.sessions,
7399        );
7400        parse_env_option_string(
7401            env,
7402            "AUTUMN_RETENTION__AUDIT_ARCHIVES",
7403            &mut self.retention.audit_archives,
7404        );
7405        parse_env_option_string(
7406            env,
7407            "AUTUMN_RETENTION__CUSTOM_DOMAINS",
7408            &mut self.retention.custom_domains,
7409        );
7410    }
7411
7412    fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
7413        if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
7414            match SchedulerBackend::from_env_value(&val) {
7415                Some(backend) => self.scheduler.backend = backend,
7416                None => eprintln!(
7417                    "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
7418                     (expected in_process, postgres or sqlite), ignoring"
7419                ),
7420            }
7421        }
7422        parse_env(
7423            env,
7424            "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
7425            &mut self.scheduler.lease_ttl_secs,
7426        );
7427        parse_env_option_string(
7428            env,
7429            "AUTUMN_SCHEDULER__REPLICA_ID",
7430            &mut self.scheduler.replica_id,
7431        );
7432        parse_env_string(
7433            env,
7434            "AUTUMN_SCHEDULER__KEY_PREFIX",
7435            &mut self.scheduler.key_prefix,
7436        );
7437    }
7438
7439    fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
7440        parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
7441        parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
7442        parse_env(
7443            env,
7444            "AUTUMN_AUTH__LOCKOUT__ENABLED",
7445            &mut self.auth.lockout.enabled,
7446        );
7447        parse_env(
7448            env,
7449            "AUTUMN_AUTH__LOCKOUT__THRESHOLD",
7450            &mut self.auth.lockout.threshold,
7451        );
7452        parse_env(
7453            env,
7454            "AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
7455            &mut self.auth.lockout.window_secs,
7456        );
7457        parse_env(
7458            env,
7459            "AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
7460            &mut self.auth.lockout.cooloff_secs,
7461        );
7462        parse_env(
7463            env,
7464            "AUTUMN_AUTH__PASSWORD__MIN_LENGTH",
7465            &mut self.auth.password.min_length,
7466        );
7467        parse_env_bool(
7468            env,
7469            "AUTUMN_AUTH__PASSWORD__REJECT_COMMON",
7470            &mut self.auth.password.reject_common,
7471        );
7472        if let Ok(val) = env.var("AUTUMN_AUTH__PASSWORD__BREACH_CHECK") {
7473            match val.as_str() {
7474                "off" => self.auth.password.breach_check = crate::auth::BreachCheck::Off,
7475                "fail_open" => self.auth.password.breach_check = crate::auth::BreachCheck::FailOpen,
7476                "fail_closed" => {
7477                    self.auth.password.breach_check = crate::auth::BreachCheck::FailClosed;
7478                }
7479                other => eprintln!(
7480                    "Warning: AUTUMN_AUTH__PASSWORD__BREACH_CHECK={other:?} is not valid \
7481                     (expected off, fail_open, or fail_closed), ignoring"
7482                ),
7483            }
7484        }
7485        parse_env_bool(
7486            env,
7487            "AUTUMN_AUTH__REMEMBER__ENABLED",
7488            &mut self.auth.remember.enabled,
7489        );
7490        parse_env(
7491            env,
7492            "AUTUMN_AUTH__REMEMBER__DURATION_SECS",
7493            &mut self.auth.remember.duration_secs,
7494        );
7495        parse_env_string(
7496            env,
7497            "AUTUMN_AUTH__REMEMBER__COOKIE_NAME",
7498            &mut self.auth.remember.cookie_name,
7499        );
7500        parse_env(
7501            env,
7502            "AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES",
7503            &mut self.auth.magic_link.ttl_minutes,
7504        );
7505        parse_env(
7506            env,
7507            "AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS",
7508            &mut self.auth.magic_link.email_cooldown_secs,
7509        );
7510        #[cfg(feature = "oauth2")]
7511        {
7512            let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
7513            for name in provider_names {
7514                let upper = name
7515                    .chars()
7516                    .map(|c| if c.is_alphanumeric() { c } else { '_' })
7517                    .collect::<String>()
7518                    .to_uppercase();
7519
7520                let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
7521                if let Ok(id) = env.var(&client_id_var)
7522                    && !id.is_empty()
7523                    && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
7524                {
7525                    p.client_id = id;
7526                }
7527
7528                let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
7529                if let Ok(secret) = env.var(&client_secret_var)
7530                    && !secret.is_empty()
7531                    && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
7532                {
7533                    p.client_secret = secret;
7534                }
7535            }
7536        }
7537    }
7538
7539    /// Apply `AUTUMN_SECURITY__*` environment variable overrides.
7540    #[allow(clippy::too_many_lines)]
7541    fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
7542        parse_env_string(
7543            env,
7544            "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
7545            &mut self.security.headers.x_frame_options,
7546        );
7547        parse_env_bool(
7548            env,
7549            "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
7550            &mut self.security.headers.x_content_type_options,
7551        );
7552        parse_env_bool(
7553            env,
7554            "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
7555            &mut self.security.headers.strict_transport_security,
7556        );
7557        parse_env(
7558            env,
7559            "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
7560            &mut self.security.headers.hsts_max_age_secs,
7561        );
7562        parse_env_string(
7563            env,
7564            "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
7565            &mut self.security.headers.content_security_policy,
7566        );
7567        parse_env_string(
7568            env,
7569            "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
7570            &mut self.security.headers.referrer_policy,
7571        );
7572        parse_env_string(
7573            env,
7574            "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
7575            &mut self.security.headers.permissions_policy,
7576        );
7577
7578        // CSRF
7579        parse_env_bool(
7580            env,
7581            "AUTUMN_SECURITY__CSRF__ENABLED",
7582            &mut self.security.csrf.enabled,
7583        );
7584        parse_env_string(
7585            env,
7586            "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
7587            &mut self.security.csrf.token_header,
7588        );
7589        parse_env_string(
7590            env,
7591            "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
7592            &mut self.security.csrf.cookie_name,
7593        );
7594        parse_env(
7595            env,
7596            "AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES",
7597            &mut self.security.csrf.token_scan_bytes,
7598        );
7599
7600        self.apply_rate_limit_env_overrides_with_env(env);
7601
7602        // Multipart uploads
7603        parse_env(
7604            env,
7605            "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
7606            &mut self.security.upload.max_request_size_bytes,
7607        );
7608        parse_env(
7609            env,
7610            "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
7611            &mut self.security.upload.max_file_size_bytes,
7612        );
7613        parse_env_csv(
7614            env,
7615            "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
7616            &mut self.security.upload.allowed_mime_types,
7617        );
7618        parse_env_bool(
7619            env,
7620            "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
7621            &mut self.security.upload.reject_on_content_type_mismatch,
7622        );
7623
7624        // Authorization deny shape + repository-API escape hatch.
7625        if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
7626            match value.parse::<crate::authorization::ForbiddenResponse>() {
7627                Ok(parsed) => self.security.forbidden_response = parsed,
7628                Err(err) => tracing::warn!(
7629                    "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
7630                ),
7631            }
7632        }
7633        parse_env_bool(
7634            env,
7635            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
7636            &mut self.security.allow_unauthorized_repository_api,
7637        );
7638
7639        // Signing secret (canonical env var documented in deployment guide)
7640        parse_env_option_string(
7641            env,
7642            "AUTUMN_SECURITY__SIGNING_SECRET",
7643            &mut self.security.signing_secret.secret,
7644        );
7645        parse_env_csv(
7646            env,
7647            "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
7648            &mut self.security.trusted_hosts.hosts,
7649        );
7650
7651        // Top-level trusted-proxy policy
7652        parse_env_csv(
7653            env,
7654            "AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
7655            &mut self.security.trusted_proxies.ranges,
7656        );
7657        parse_env_bool(
7658            env,
7659            "AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
7660            &mut self.security.trusted_proxies.trust_forwarded_headers,
7661        );
7662        if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
7663            if let Ok(hops) = val.trim().parse::<u32>() {
7664                self.security.trusted_proxies.trusted_hops = Some(hops);
7665            } else {
7666                tracing::warn!(
7667                    "ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
7668                     expected a non-negative integer"
7669                );
7670            }
7671        }
7672
7673        self.security.webhooks.apply_env_overrides_with_env(env);
7674    }
7675
7676    fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
7677        parse_env_bool(
7678            env,
7679            "AUTUMN_BOT_PROTECTION__ENABLED",
7680            &mut self.bot_protection.enabled,
7681        );
7682        parse_env_bool(
7683            env,
7684            "AUTUMN_BOT_PROTECTION__DEV_BYPASS",
7685            &mut self.bot_protection.dev_bypass,
7686        );
7687        if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
7688            match val.to_lowercase().as_str() {
7689                "turnstile" => {
7690                    self.bot_protection.provider =
7691                        crate::security::captcha::CaptchaProviderKind::Turnstile;
7692                }
7693                "hcaptcha" => {
7694                    self.bot_protection.provider =
7695                        crate::security::captcha::CaptchaProviderKind::HCaptcha;
7696                }
7697                _ => tracing::warn!(
7698                    "ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
7699                     expected \"turnstile\" or \"hcaptcha\""
7700                ),
7701            }
7702        }
7703        parse_env_option_string(
7704            env,
7705            "AUTUMN_BOT_PROTECTION__SITE_KEY",
7706            &mut self.bot_protection.site_key,
7707        );
7708        parse_env_option_string(
7709            env,
7710            "AUTUMN_BOT_PROTECTION__SECRET_KEY",
7711            &mut self.bot_protection.secret_key,
7712        );
7713        parse_env_option_string(
7714            env,
7715            "AUTUMN_BOT_PROTECTION__FORM_FIELD",
7716            &mut self.bot_protection.form_field,
7717        );
7718    }
7719
7720    fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
7721        parse_env_bool(
7722            env,
7723            "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
7724            &mut self.security.rate_limit.enabled,
7725        );
7726        parse_env(
7727            env,
7728            "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
7729            &mut self.security.rate_limit.requests_per_second,
7730        );
7731        parse_env(
7732            env,
7733            "AUTUMN_SECURITY__RATE_LIMIT__BURST",
7734            &mut self.security.rate_limit.burst,
7735        );
7736        parse_env_bool(
7737            env,
7738            "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
7739            &mut self.security.rate_limit.trust_forwarded_headers,
7740        );
7741        parse_env_csv(
7742            env,
7743            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
7744            &mut self.security.rate_limit.trusted_proxies,
7745        );
7746        if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
7747            match crate::security::config::KeyStrategy::from_env_value(&val) {
7748                Some(strategy) => self.security.rate_limit.key_strategy = strategy,
7749                None => eprintln!(
7750                    "Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
7751                     (expected ip, api_token, or authenticated_principal), ignoring"
7752                ),
7753            }
7754        }
7755        // BACKEND is always parsed so misconfiguration is surfaced even without
7756        // the redis feature (build_backend will warn and fall back to memory).
7757        if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
7758            match crate::security::config::RateLimitBackend::from_env_value(&val) {
7759                Some(backend) => self.security.rate_limit.backend = backend,
7760                None => eprintln!(
7761                    "Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
7762                     (expected memory or redis), ignoring"
7763                ),
7764            }
7765        }
7766        #[cfg(feature = "redis")]
7767        {
7768            use crate::security::config::RateLimitBackendFailure;
7769            if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
7770                match RateLimitBackendFailure::from_env_value(&val) {
7771                    Some(mode) => self.security.rate_limit.on_backend_failure = mode,
7772                    None => eprintln!(
7773                        "Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
7774                         valid (expected fail_open or fail_closed), ignoring"
7775                    ),
7776                }
7777            }
7778            parse_env_option_string(
7779                env,
7780                "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
7781                &mut self.security.rate_limit.redis.url,
7782            );
7783            parse_env_string(
7784                env,
7785                "AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
7786                &mut self.security.rate_limit.redis.key_prefix,
7787            );
7788        }
7789    }
7790
7791    #[cfg(feature = "storage")]
7792    fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
7793        if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
7794            match crate::storage::StorageBackend::from_env_value(&val) {
7795                Some(backend) => self.storage.backend = backend,
7796                None => eprintln!(
7797                    "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
7798                     (expected disabled, local, or s3), ignoring"
7799                ),
7800            }
7801        }
7802        parse_env_string(
7803            env,
7804            "AUTUMN_STORAGE__DEFAULT_PROVIDER",
7805            &mut self.storage.default_provider,
7806        );
7807        parse_env_bool(
7808            env,
7809            "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
7810            &mut self.storage.allow_local_in_production,
7811        );
7812        if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
7813            self.storage.local.root = PathBuf::from(val);
7814        }
7815        parse_env_string(
7816            env,
7817            "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
7818            &mut self.storage.local.mount_path,
7819        );
7820        parse_env(
7821            env,
7822            "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
7823            &mut self.storage.local.default_url_expiry_secs,
7824        );
7825        parse_env_option_string(
7826            env,
7827            "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
7828            &mut self.storage.local.signing_key,
7829        );
7830        parse_env_option_string(
7831            env,
7832            "AUTUMN_STORAGE__S3__BUCKET",
7833            &mut self.storage.s3.bucket,
7834        );
7835        parse_env_option_string(
7836            env,
7837            "AUTUMN_STORAGE__S3__REGION",
7838            &mut self.storage.s3.region,
7839        );
7840        parse_env_option_string(
7841            env,
7842            "AUTUMN_STORAGE__S3__ENDPOINT",
7843            &mut self.storage.s3.endpoint,
7844        );
7845        parse_env_option_string(
7846            env,
7847            "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
7848            &mut self.storage.s3.public_base_url,
7849        );
7850        parse_env_option_string(
7851            env,
7852            "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
7853            &mut self.storage.s3.access_key_id_env,
7854        );
7855        parse_env_option_string(
7856            env,
7857            "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
7858            &mut self.storage.s3.secret_access_key_env,
7859        );
7860        parse_env_bool(
7861            env,
7862            "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
7863            &mut self.storage.s3.force_path_style,
7864        );
7865        parse_env(
7866            env,
7867            "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
7868            &mut self.storage.s3.default_url_expiry_secs,
7869        );
7870        parse_env(
7871            env,
7872            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
7873            &mut self.storage.variants.max_source_bytes,
7874        );
7875        parse_env(
7876            env,
7877            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
7878            &mut self.storage.variants.max_source_width,
7879        );
7880        parse_env(
7881            env,
7882            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
7883            &mut self.storage.variants.max_source_height,
7884        );
7885    }
7886
7887    /// Apply `AUTUMN_BACKUP__OFFSITE__*` overrides to the `[backup.offsite]`
7888    /// section (issue #1619). Mirrors the storage overrides so the offsite
7889    /// destination honors the same `AUTUMN_*` env convention. When no offsite
7890    /// section exists in TOML, a default one is materialized only if at least
7891    /// one offsite env var is present, so an all-env deployment still works.
7892    fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
7893        // Keys that signal genuine intent to configure an offsite destination: the
7894        // presence of any required destination or credential key materializes the
7895        // `[backup.offsite]` section (#1791). The list is limited to what a working
7896        // upload requires — a bucket, or the access and secret key-env names.
7897        // Optional-only keys (`region`, `force_path_style`, `endpoint`, `prefix`,
7898        // `keep`) do not materialize the section on their own: a bare
7899        // `AUTUMN_BACKUP__OFFSITE__S3__REGION` with no bucket cannot upload, so it must
7900        // leave offsite unconfigured rather than produce an empty section that then
7901        // fails validation or `doctor` with "backup.offsite.s3.bucket is unset". Those
7902        // optional keys are still applied below once a required key materializes the
7903        // section. The two opt-out toggles are excluded too: a lone `AUTO_UPLOAD=false`
7904        // or `ALLOW_SHARED_BUCKET=false` must not create an otherwise-empty section
7905        // (#1619 P2 #18). A truthy `AUTO_UPLOAD=true` does materialize, since it needs a
7906        // validated destination to act on.
7907        const OFFSITE_DEST_KEYS: &[&str] = &[
7908            "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
7909            "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
7910            "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
7911        ];
7912        let has_dest_key = OFFSITE_DEST_KEYS.iter().any(|k| env.var(k).is_ok());
7913        let auto_upload_truthy = env
7914            .var("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD")
7915            .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
7916        if self.backup.offsite.is_none() && !has_dest_key && !auto_upload_truthy {
7917            return;
7918        }
7919        let offsite = self
7920            .backup
7921            .offsite
7922            .get_or_insert_with(|| Box::new(OffsiteBackupConfig::default()));
7923        parse_env_option_string(
7924            env,
7925            "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
7926            &mut offsite.s3.bucket,
7927        );
7928        parse_env_option_string(
7929            env,
7930            "AUTUMN_BACKUP__OFFSITE__S3__REGION",
7931            &mut offsite.s3.region,
7932        );
7933        parse_env_option_string(
7934            env,
7935            "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
7936            &mut offsite.s3.endpoint,
7937        );
7938        parse_env_option_string(
7939            env,
7940            "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
7941            &mut offsite.s3.access_key_id_env,
7942        );
7943        parse_env_option_string(
7944            env,
7945            "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
7946            &mut offsite.s3.secret_access_key_env,
7947        );
7948        parse_env_bool(
7949            env,
7950            "AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE",
7951            &mut offsite.s3.force_path_style,
7952        );
7953        parse_env_option_string(env, "AUTUMN_BACKUP__OFFSITE__PREFIX", &mut offsite.prefix);
7954        parse_env_option(env, "AUTUMN_BACKUP__OFFSITE__KEEP", &mut offsite.keep);
7955        parse_env_bool(
7956            env,
7957            "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
7958            &mut offsite.auto_upload,
7959        );
7960        parse_env_bool(
7961            env,
7962            "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
7963            &mut offsite.allow_shared_bucket,
7964        );
7965    }
7966
7967    /// Apply `AUTUMN_REPLICATION__*` overrides to the `[replication]` section
7968    /// (issue #1628). Mirrors the `[backup.offsite]` convention: a *destination*
7969    /// key materializes the section for an all-env deployment, while
7970    /// optional-only keys never conjure a section that would then fail
7971    /// validation.
7972    fn apply_replication_env_overrides_with_env(&mut self, env: &dyn Env) {
7973        // Keys that express a real intent to configure a destination. A lone
7974        // region/endpoint/prefix cannot replicate anywhere, so it must not
7975        // materialize the section; `ENABLED=true` does, because it needs a
7976        // destination to act on and the resulting validation error is the honest
7977        // answer.
7978        const DEST_KEYS: &[&str] = &[
7979            "AUTUMN_REPLICATION__S3__BUCKET",
7980            "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
7981            "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
7982            "AUTUMN_REPLICATION__PATH",
7983        ];
7984        // Only a REQUIRED S3 key materializes the sub-section, exactly as
7985        // `OFFSITE_DEST_KEYS` does for `[backup.offsite]`. A stray
7986        // `AUTUMN_REPLICATION__S3__REGION` next to a `path` destination must not
7987        // conjure an empty `[replication.s3]` — that would make the section
7988        // "configures both s3 and path" and fail boot on an otherwise valid
7989        // config. The optional keys are still APPLIED once a required one has
7990        // materialized the sub-section.
7991        const S3_KEYS: &[&str] = &[
7992            "AUTUMN_REPLICATION__S3__BUCKET",
7993            "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
7994            "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
7995        ];
7996        let has_dest_key = DEST_KEYS.iter().any(|k| env.var(k).is_ok());
7997        let enabled_truthy = env
7998            .var("AUTUMN_REPLICATION__ENABLED")
7999            .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
8000        if self.replication.is_none() && !has_dest_key && !enabled_truthy {
8001            return;
8002        }
8003        let replication = self
8004            .replication
8005            .get_or_insert_with(|| Box::new(ReplicationConfig::default()));
8006
8007        parse_env_bool(env, "AUTUMN_REPLICATION__ENABLED", &mut replication.enabled);
8008        parse_env(
8009            env,
8010            "AUTUMN_REPLICATION__RPO_SECS",
8011            &mut replication.rpo_secs,
8012        );
8013        parse_env_option(
8014            env,
8015            "AUTUMN_REPLICATION__SYNC_INTERVAL_SECS",
8016            &mut replication.sync_interval_secs,
8017        );
8018        parse_env(
8019            env,
8020            "AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS",
8021            &mut replication.snapshot_interval_secs,
8022        );
8023        parse_env(
8024            env,
8025            "AUTUMN_REPLICATION__MAX_WAL_BYTES",
8026            &mut replication.max_wal_bytes,
8027        );
8028        parse_env(
8029            env,
8030            "AUTUMN_REPLICATION__RETENTION_HOURS",
8031            &mut replication.retention_hours,
8032        );
8033        parse_env(
8034            env,
8035            "AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS",
8036            &mut replication.verify_interval_secs,
8037        );
8038        parse_env_option_string(env, "AUTUMN_REPLICATION__PREFIX", &mut replication.prefix);
8039        parse_env_option_string(env, "AUTUMN_REPLICATION__PATH", &mut replication.path);
8040        parse_env_bool(
8041            env,
8042            "AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET",
8043            &mut replication.allow_shared_bucket,
8044        );
8045
8046        if replication.s3.is_none() && !S3_KEYS.iter().any(|k| env.var(k).is_ok()) {
8047            return;
8048        }
8049        let s3 = replication
8050            .s3
8051            .get_or_insert_with(ReplicationS3Config::default);
8052        parse_env_option_string(env, "AUTUMN_REPLICATION__S3__BUCKET", &mut s3.bucket);
8053        parse_env_option_string(env, "AUTUMN_REPLICATION__S3__REGION", &mut s3.region);
8054        parse_env_option_string(env, "AUTUMN_REPLICATION__S3__ENDPOINT", &mut s3.endpoint);
8055        parse_env_option_string(
8056            env,
8057            "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
8058            &mut s3.access_key_id_env,
8059        );
8060        parse_env_option_string(
8061            env,
8062            "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
8063            &mut s3.secret_access_key_env,
8064        );
8065        parse_env_bool(
8066            env,
8067            "AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE",
8068            &mut s3.force_path_style,
8069        );
8070    }
8071
8072    #[cfg(feature = "mail")]
8073    fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
8074        if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
8075            match crate::mail::Transport::from_env_value(&val) {
8076                Some(transport) => self.mail.transport = transport,
8077                None => eprintln!(
8078                    "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
8079                     (expected log, file, smtp, or disabled), ignoring"
8080                ),
8081            }
8082        }
8083        parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
8084        parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
8085        parse_env_bool(
8086            env,
8087            "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
8088            &mut self.mail.allow_log_in_production,
8089        );
8090        parse_env_bool(
8091            env,
8092            "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
8093            &mut self.mail.allow_in_process_deliver_later_in_production,
8094        );
8095        parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
8096        parse_env_option_string(
8097            env,
8098            "AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL",
8099            &mut self.mail.unsubscribe_base_url,
8100        );
8101        parse_env_option_string(
8102            env,
8103            "AUTUMN_MAIL__UNSUBSCRIBE_MAILTO",
8104            &mut self.mail.unsubscribe_mailto,
8105        );
8106        if let Ok(val) = env.var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS") {
8107            match val.parse::<i64>() {
8108                Ok(days) => self.mail.unsubscribe_token_ttl_days = days,
8109                Err(_) => eprintln!(
8110                    "Warning: AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS={val:?} is not a valid integer, ignoring"
8111                ),
8112            }
8113        }
8114        parse_env_bool(
8115            env,
8116            "AUTUMN_MAIL__MOUNT_UNSUBSCRIBE_ENDPOINT",
8117            &mut self.mail.mount_unsubscribe_endpoint,
8118        );
8119        parse_env_bool(env, "AUTUMN_MAIL__INLINE_CSS", &mut self.mail.inline_css);
8120        if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
8121            self.mail.file_dir = PathBuf::from(val);
8122        }
8123        parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
8124        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
8125            match val.parse::<u16>() {
8126                Ok(port) => self.mail.smtp.port = Some(port),
8127                Err(_) => {
8128                    eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
8129                }
8130            }
8131        }
8132        parse_env_option_string(
8133            env,
8134            "AUTUMN_MAIL__SMTP__USERNAME",
8135            &mut self.mail.smtp.username,
8136        );
8137        parse_env_option_string(
8138            env,
8139            "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
8140            &mut self.mail.smtp.password_env,
8141        );
8142        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
8143            match crate::mail::TlsMode::from_env_value(&val) {
8144                Some(tls) => self.mail.smtp.tls = tls,
8145                None => eprintln!(
8146                    "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
8147                     (expected disabled, starttls, or tls), ignoring"
8148                ),
8149            }
8150        }
8151    }
8152
8153    /// Returns the active profile name, if any.
8154    #[must_use]
8155    pub fn profile_name(&self) -> Option<&str> {
8156        self.profile.as_deref()
8157    }
8158}
8159
8160/// Per-request timeout configuration.
8161///
8162/// Controls how long the server waits for a complete request-response cycle
8163/// before it returns `503 Service Unavailable`. A value of `None` or `0`
8164/// disables the timeout. The default is disabled. The `prod` profile sets
8165/// `30000` (30s).
8166///
8167/// # `autumn.toml` example
8168///
8169/// ```toml
8170/// [server.timeouts]
8171/// request_timeout_ms = 30000  # 30 seconds
8172/// ```
8173#[derive(Debug, Clone, Default, Deserialize)]
8174pub struct RequestTimeoutsConfig {
8175    /// Maximum time in milliseconds allowed for a complete request-response
8176    /// cycle. When exceeded the framework returns `503 Service Unavailable`
8177    /// rendered as Problem Details JSON for API clients (and the standard error
8178    /// page for browser requests). `None` (default) or `0` disables the timeout.
8179    ///
8180    /// The deadline bounds the time to produce the response *head*: once the
8181    /// status and headers are sent, the streaming body is not interrupted, so
8182    /// SSE, chunked responses, and WebSocket upgrades (all of which emit their
8183    /// head promptly and then stream) run unbounded afterward. Long-poll
8184    /// handlers are the exception — they intentionally withhold the response
8185    /// head while waiting for data, so they *are* subject to this deadline and
8186    /// will return `503` if it fires before they respond. Give such routes a
8187    /// per-route override via the route macro
8188    /// (`#[get("/poll", timeout_ms = 120000)]` or `timeout = "off"`), which is
8189    /// also how any other slow route can raise or disable its own deadline.
8190    ///
8191    /// A second exception applies to *mutating* requests carrying an
8192    /// `Idempotency-Key`: the idempotency layer buffers the full response body
8193    /// (so the response can be cached and replayed) before the head is returned,
8194    /// so those responses are bounded by the deadline even when the handler
8195    /// streams them. Give such endpoints a per-route override if they
8196    /// legitimately produce slow or large idempotent bodies.
8197    ///
8198    /// The `prod` profile smart-defaults this to `30000` (30s); `dev` and custom
8199    /// profiles leave it disabled. Configured via
8200    /// `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS`.
8201    #[serde(default)]
8202    pub request_timeout_ms: Option<u64>,
8203
8204    /// Read the caller's `x-autumn-deadline-ms` header (issue #3058). The
8205    /// header can only make the route deadline shorter. A route with no
8206    /// deadline ignores it. Default: `false`. Configured via
8207    /// `AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER`.
8208    #[serde(default)]
8209    pub accept_deadline_header: bool,
8210}
8211
8212/// `[server.http]` — connection limits for the HTTP server (issue #3065).
8213///
8214/// Every key is optional. When a key is not set, the server applies no limit
8215/// for it, or the hyper default. The `prod` profile sets safe values for all
8216/// keys.
8217///
8218/// ```toml
8219/// [server.http]
8220/// header_read_timeout_ms = 10000
8221/// keep_alive_timeout_ms = 75000
8222/// max_header_bytes = 65536
8223/// http2_max_concurrent_streams = 100
8224/// max_connections = 10000
8225/// ```
8226#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
8227pub struct HttpServerConfig {
8228    /// Maximum time to receive a full request head. The timer starts when the
8229    /// connection opens, or at the first byte after an idle period. The server
8230    /// disconnects a client that is too slow. This stops slowloris attacks.
8231    /// `None` or `0` disables it.
8232    #[serde(default)]
8233    pub header_read_timeout_ms: Option<u64>,
8234
8235    /// Maximum time a connection stays open with no request in flight. `None`
8236    /// or `0` disables it.
8237    #[serde(default)]
8238    pub keep_alive_timeout_ms: Option<u64>,
8239
8240    /// Maximum size of the request head, in bytes. HTTP/1 requests over the
8241    /// limit get `431`. Must be `8192` or more.
8242    #[serde(default)]
8243    pub max_header_bytes: Option<usize>,
8244
8245    /// Maximum number of concurrent HTTP/2 streams on one connection.
8246    #[serde(default)]
8247    pub http2_max_concurrent_streams: Option<u32>,
8248
8249    /// Maximum number of open connections. At the limit, the server stops
8250    /// accepting until a connection closes. Upgraded connections (WebSocket)
8251    /// count until they close. `None` or `0` disables it.
8252    #[serde(default)]
8253    pub max_connections: Option<usize>,
8254}
8255
8256impl HttpServerConfig {
8257    /// Smallest `max_header_bytes` the server accepts.
8258    pub const MIN_HEADER_BYTES: usize = 8192;
8259
8260    /// Largest `max_connections` the server accepts: the most permits a
8261    /// Tokio semaphore can hold.
8262    pub const MAX_CONNECTIONS: usize = tokio::sync::Semaphore::MAX_PERMITS;
8263
8264    /// Reject values the server cannot use.
8265    ///
8266    /// # Errors
8267    ///
8268    /// Returns [`ConfigError::Validation`] when `max_header_bytes` is below
8269    /// [`Self::MIN_HEADER_BYTES`], `http2_max_concurrent_streams` is `0`, or
8270    /// `max_connections` is above [`Self::MAX_CONNECTIONS`].
8271    pub fn validate(&self) -> Result<(), ConfigError> {
8272        if let Some(bytes) = self.max_header_bytes
8273            && bytes < Self::MIN_HEADER_BYTES
8274        {
8275            return Err(ConfigError::Validation(format!(
8276                "server.http.max_header_bytes must be {} or more (got {bytes})",
8277                Self::MIN_HEADER_BYTES
8278            )));
8279        }
8280        if self.http2_max_concurrent_streams == Some(0) {
8281            return Err(ConfigError::Validation(
8282                "server.http.http2_max_concurrent_streams must be greater than zero".to_owned(),
8283            ));
8284        }
8285        if let Some(max) = self.max_connections
8286            && max > Self::MAX_CONNECTIONS
8287        {
8288            return Err(ConfigError::Validation(format!(
8289                "server.http.max_connections must be {} or less (got {max})",
8290                Self::MAX_CONNECTIONS
8291            )));
8292        }
8293        Ok(())
8294    }
8295}
8296
8297/// `[server.upgrade]` — in-place upgrades (issue #1674).
8298///
8299/// On `SIGUSR2` a running app hands its listening socket and its designated
8300/// live state to a freshly-execed build, waits for that build to serve, and
8301/// only then drains itself. See `docs/guide/hot-upgrades.md`.
8302#[derive(Debug, Clone, Deserialize)]
8303pub struct UpgradeConfig {
8304    /// Whether `SIGUSR2` triggers an in-place upgrade. Default: `true`.
8305    ///
8306    /// With this off the signal is logged and ignored — which is still safer
8307    /// than the default disposition of `SIGUSR2`, which terminates the process.
8308    #[serde(default = "default_upgrade_enabled")]
8309    pub enabled: bool,
8310
8311    /// Seconds to wait for the successor to signal that it is serving before
8312    /// abandoning the upgrade and carrying on with the current build.
8313    /// Default: `30`.
8314    ///
8315    /// The wait ends early — with the upgrade abandoned — if the successor
8316    /// exits first, so this only bounds a successor that hangs during startup.
8317    #[serde(default = "default_upgrade_ready_timeout")]
8318    pub ready_timeout_secs: u64,
8319}
8320
8321/// HTTP server configuration.
8322///
8323/// Controls which address the server binds to and how graceful shutdown
8324/// behaves.
8325///
8326/// # Defaults
8327///
8328/// | Field | Default |
8329/// |-------|---------|
8330/// | `port` | `3000` |
8331/// | `host` | `"127.0.0.1"` |
8332/// | `shutdown_timeout_secs` | `30` |
8333///
8334/// # Examples
8335///
8336/// ```rust
8337/// use autumn_web::config::ServerConfig;
8338///
8339/// let server = ServerConfig::default();
8340/// assert_eq!(server.port, 3000);
8341/// assert_eq!(server.host, "127.0.0.1");
8342/// ```
8343#[derive(Debug, Clone, Deserialize)]
8344pub struct ServerConfig {
8345    /// Port to listen on. Default: `3000`.
8346    #[serde(default = "default_port")]
8347    pub port: u16,
8348
8349    /// Host/IP to bind to. Default: `"127.0.0.1"`.
8350    ///
8351    /// Set to `"0.0.0.0"` to accept connections from all interfaces
8352    /// (typical for containerized deployments).
8353    #[serde(default = "default_host")]
8354    pub host: String,
8355
8356    /// Exit startup if any unknown config keys are found in autumn.toml/profiles.
8357    ///
8358    /// The `prod` profile sets `true` (#3057). Override via
8359    /// `AUTUMN_SERVER__STRICT_CONFIG`.
8360    #[serde(default)]
8361    pub strict_config: bool,
8362
8363    /// When `strict_config` is enabled, also hard-fail on unknown keys in the
8364    /// config sections that only became strictly validated by the #1890
8365    /// schema-walk fix (everything except `server`, `deploy`, and `database`,
8366    /// whose keys were already validated). Defaults to `false` for one release:
8367    /// unknown keys in those newly-covered sections WARN loudly at startup
8368    /// instead of failing, so configs that silently passed before keep booting.
8369    /// Set to `true` to enforce immediately; a future release makes `true` the
8370    /// default and removes this transitional gate.
8371    #[serde(default)]
8372    pub strict_config_enforce_all: bool,
8373
8374    /// Seconds to wait for in-flight requests during graceful shutdown.
8375    /// Default: `30`.
8376    ///
8377    /// When the server receives a shutdown signal, it stops accepting
8378    /// new connections and waits up to this many seconds for in-flight
8379    /// requests to complete before forcibly terminating.
8380    #[serde(default = "default_shutdown_timeout")]
8381    pub shutdown_timeout_secs: u64,
8382
8383    /// Seconds between `/ready` returning 503 and the TCP listener
8384    /// closing to new connections. Default: `5`.
8385    ///
8386    /// This gap gives upstream load balancers time to deregister the
8387    /// replica before it stops accepting new connections, preventing
8388    /// connection resets on in-flight requests from the LB tier.
8389    /// Must be tuned to match the LB's health-check interval + deregistration
8390    /// propagation time. Set to `0` to disable the grace period.
8391    #[serde(default = "default_prestop_grace")]
8392    pub prestop_grace_secs: u64,
8393
8394    /// In-place upgrade settings (`SIGUSR2` handoff to a new binary).
8395    ///
8396    /// See [`UpgradeConfig`] and `docs/guide/hot-upgrades.md`.
8397    #[serde(default)]
8398    pub upgrade: UpgradeConfig,
8399
8400    /// Per-request timeout configuration.
8401    ///
8402    /// Controls request-cycle timeouts for `DoS` protection. By default
8403    /// all timeouts are disabled so existing applications are unaffected.
8404    /// Set `request_timeout_ms` in `[server.timeouts]` to enable.
8405    #[serde(default)]
8406    pub timeouts: RequestTimeoutsConfig,
8407
8408    /// Connection limits. See [`HttpServerConfig`].
8409    #[serde(default)]
8410    pub http: HttpServerConfig,
8411
8412    /// Bind to a Unix domain socket at this path instead of `host:port`.
8413    ///
8414    /// When set, the server binds a `UnixListener` at the given path
8415    /// (replacing the TCP `host:port` bind) — the local-daemon transport
8416    /// used by `autumn serve`. The socket is created with `0600`
8417    /// permissions and removed on graceful shutdown. Unix-only; on other
8418    /// platforms a configured value is rejected at startup.
8419    ///
8420    /// Configured via `AUTUMN_SERVER__UNIX_SOCKET`. Default: `None` (TCP).
8421    #[serde(default)]
8422    pub unix_socket: Option<String>,
8423
8424    /// Ceiling on concurrent in-flight requests (admission control / load
8425    /// shedding). `0` disables the ceiling. `None` (the default) uses the
8426    /// capacity contract, then the profile default: primary pool size ×
8427    /// [`PROD_REQUESTS_PER_POOL_CONNECTION`] under `prod` (#3057), no ceiling
8428    /// under other profiles.
8429    ///
8430    /// Once this many requests are admitted and still in flight, additional
8431    /// requests receive an immediate `503 Service Unavailable` with a
8432    /// `Retry-After` header, before the handler runs or the request body is
8433    /// read. This bounds total concurrent work (and therefore memory) under
8434    /// a traffic spike or a slow dependency, trading a fast, clean "try
8435    /// another replica" signal for the alternative — admitted requests
8436    /// piling up unbounded until the process is OOM-killed.
8437    ///
8438    /// Liveness/readiness/health probe routes (`health.*` paths and the
8439    /// actuator prefix) are never shed, so a merely-busy replica is not
8440    /// killed by its orchestrator.
8441    ///
8442    /// A reasonable starting point is the number of worker threads times a
8443    /// small multiple (e.g. 2-4x), sized to keep admitted-request tail
8444    /// latency stable under the expected peak concurrency; tune based on
8445    /// observed `autumn_requests_shed_total` and per-route latency.
8446    ///
8447    /// Configured via `AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS`.
8448    #[serde(default)]
8449    pub max_concurrent_requests: Option<usize>,
8450
8451    /// Path to the committed capacity contract (`capacity.lock`) this deploy
8452    /// should admit against (issue #1733).
8453    ///
8454    /// When set — and [`Self::max_concurrent_requests`] is *not* — the
8455    /// load-shedding ceiling is sourced from the contract's proven envelope
8456    /// instead of a hand-tuned guess, so the binary sheds at the edge someone
8457    /// actually measured. Relative paths resolve against the process working
8458    /// directory.
8459    ///
8460    /// An explicit `max_concurrent_requests` always wins, and every failure
8461    /// along the contract path (missing file, malformed document, a contract
8462    /// measured on a different host class) degrades to the profile default
8463    /// (unlimited outside `prod`) with a warning — see
8464    /// [`capacity::resolve_admission_limit_with_default`](crate::capacity::resolve_admission_limit_with_default).
8465    ///
8466    /// Configured via `AUTUMN_SERVER__CAPACITY_CONTRACT`.
8467    #[serde(default)]
8468    pub capacity_contract: Option<String>,
8469
8470    /// Terminate HTTPS directly in the app process (issue #1603).
8471    ///
8472    /// When set, the server serves TLS on `host:port` using the configured
8473    /// certificate chain and private key — no sidecar reverse proxy required.
8474    /// Absent (the default), the server keeps serving plain HTTP, so existing
8475    /// applications are unaffected.
8476    ///
8477    /// This field is always parsed, regardless of build features, so a
8478    /// misconfiguration is a clear "built without the `tls` feature" error
8479    /// rather than a silently-ignored section. The serving code itself is
8480    /// gated behind the off-by-default `tls` feature.
8481    ///
8482    /// Configured via `[server.tls]` (`cert_path`, `key_path`,
8483    /// `reload_interval_secs`, `handshake_timeout_secs`) or the matching
8484    /// `AUTUMN_SERVER__TLS__CERT_PATH` / `AUTUMN_SERVER__TLS__KEY_PATH` /
8485    /// `AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS` /
8486    /// `AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS` env vars.
8487    #[serde(default)]
8488    pub tls: Option<TlsConfig>,
8489
8490    /// Admission control: static or adaptive limit, and criticality
8491    /// partitions (issue #3068). See [`AdmissionConfig`].
8492    ///
8493    /// Configured via `[server.admission]` or `AUTUMN_SERVER__ADMISSION__*`.
8494    #[serde(default)]
8495    pub admission: AdmissionConfig,
8496}
8497
8498/// How admission control sets its concurrency limit.
8499#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8500#[serde(rename_all = "lowercase")]
8501#[non_exhaustive]
8502pub enum AdmissionMode {
8503    /// A fixed ceiling from `server.max_concurrent_requests`, the capacity
8504    /// contract, or the profile default.
8505    #[default]
8506    Static,
8507    /// A ceiling that follows measured latency. See [`AdmissionAlgorithm`].
8508    Adaptive,
8509}
8510
8511impl std::str::FromStr for AdmissionMode {
8512    type Err = String;
8513
8514    fn from_str(s: &str) -> Result<Self, Self::Err> {
8515        match s.trim().to_ascii_lowercase().as_str() {
8516            "static" => Ok(Self::Static),
8517            "adaptive" => Ok(Self::Adaptive),
8518            other => Err(format!("unknown admission mode {other:?}")),
8519        }
8520    }
8521}
8522
8523/// The algorithm that moves an adaptive limit.
8524#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8525#[serde(rename_all = "lowercase")]
8526#[non_exhaustive]
8527pub enum AdmissionAlgorithm {
8528    /// Netflix Gradient2: compares the latest RTT with a long-term average.
8529    #[default]
8530    Gradient2,
8531    /// Netflix Vegas: estimates the queue from the lowest RTT seen.
8532    Vegas,
8533    /// Additive increase, multiplicative decrease. It backs off on a `504`,
8534    /// a cancel at the request deadline, or a response slower than
8535    /// [`AdmissionConfig::latency_threshold_ms`].
8536    Aimd,
8537}
8538
8539impl std::str::FromStr for AdmissionAlgorithm {
8540    type Err = String;
8541
8542    fn from_str(s: &str) -> Result<Self, Self::Err> {
8543        match s.trim().to_ascii_lowercase().as_str() {
8544            "gradient2" => Ok(Self::Gradient2),
8545            "vegas" => Ok(Self::Vegas),
8546            "aimd" => Ok(Self::Aimd),
8547            other => Err(format!("unknown admission algorithm {other:?}")),
8548        }
8549    }
8550}
8551
8552/// `[server.admission]`: admission control settings (issue #3068).
8553///
8554/// ```toml
8555/// [server.admission]
8556/// mode = "adaptive"          # "static" (default) or "adaptive"
8557/// algorithm = "gradient2"    # "gradient2" (default), "vegas" or "aimd"
8558/// min_limit = 8
8559/// max_limit = 1000           # default: the static ceiling, else 1000
8560/// initial_limit = 20
8561/// trust_criticality_header = false
8562///
8563/// [server.admission.partitions]
8564/// default = 1.0              # share of the limit for `default` requests
8565/// sheddable = 0.5            # share of the limit for `sheddable` requests
8566/// ```
8567///
8568/// The defaults change nothing for routes without a criticality.
8569#[derive(Debug, Clone, PartialEq, Deserialize)]
8570#[non_exhaustive]
8571pub struct AdmissionConfig {
8572    /// `static` (default) or `adaptive`.
8573    #[serde(default)]
8574    pub mode: AdmissionMode,
8575    /// The adaptive algorithm. Not used in `static` mode.
8576    #[serde(default)]
8577    pub algorithm: AdmissionAlgorithm,
8578    /// The lowest adaptive limit. At least 1. Default: 8.
8579    #[serde(default = "default_admission_min_limit")]
8580    pub min_limit: usize,
8581    /// The highest adaptive limit. Default: the static ceiling (from
8582    /// `max_concurrent_requests`, the capacity contract or the profile), or
8583    /// [`DEFAULT_ADMISSION_MAX_LIMIT`] when there is none.
8584    #[serde(default)]
8585    pub max_limit: Option<usize>,
8586    /// The adaptive limit before the first sample. Default: 20.
8587    #[serde(default = "default_admission_initial_limit")]
8588    pub initial_limit: usize,
8589    /// For `aimd`: a response slower than this backs the limit off.
8590    /// Default: 1000 ms.
8591    #[serde(default = "default_admission_latency_threshold_ms")]
8592    pub latency_threshold_ms: u64,
8593    /// Read the criticality of an inbound request from the
8594    /// `X-Autumn-Criticality` header. The header replaces the route's value.
8595    /// Default: `false`. Set it only when you trust all callers, or when an
8596    /// edge proxy removes or sets the header.
8597    #[serde(default)]
8598    pub trust_criticality_header: bool,
8599    /// The share of the limit for each criticality.
8600    #[serde(default)]
8601    pub partitions: AdmissionPartitionsConfig,
8602}
8603
8604/// The highest adaptive limit when no static ceiling applies.
8605pub const DEFAULT_ADMISSION_MAX_LIMIT: usize = 1000;
8606
8607const fn default_admission_min_limit() -> usize {
8608    8
8609}
8610
8611const fn default_admission_initial_limit() -> usize {
8612    20
8613}
8614
8615const fn default_admission_latency_threshold_ms() -> u64 {
8616    1000
8617}
8618
8619impl Default for AdmissionConfig {
8620    fn default() -> Self {
8621        Self {
8622            mode: AdmissionMode::default(),
8623            algorithm: AdmissionAlgorithm::default(),
8624            min_limit: default_admission_min_limit(),
8625            max_limit: None,
8626            initial_limit: default_admission_initial_limit(),
8627            latency_threshold_ms: default_admission_latency_threshold_ms(),
8628            trust_criticality_header: false,
8629            partitions: AdmissionPartitionsConfig::default(),
8630        }
8631    }
8632}
8633
8634/// `[server.admission.partitions]`: the share of the limit that each
8635/// criticality can fill. `critical` always gets the full limit.
8636#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
8637#[non_exhaustive]
8638pub struct AdmissionPartitionsConfig {
8639    /// The share for `default` requests, in `0.0..=1.0`. Default: 1.0.
8640    #[serde(default = "default_partition_default")]
8641    pub default: f64,
8642    /// The share for `sheddable` requests, in `0.0..=default`. Default: 0.5.
8643    #[serde(default = "default_partition_sheddable")]
8644    pub sheddable: f64,
8645}
8646
8647const fn default_partition_default() -> f64 {
8648    1.0
8649}
8650
8651const fn default_partition_sheddable() -> f64 {
8652    0.5
8653}
8654
8655impl Default for AdmissionPartitionsConfig {
8656    fn default() -> Self {
8657        Self {
8658            default: default_partition_default(),
8659            sheddable: default_partition_sheddable(),
8660        }
8661    }
8662}
8663
8664impl AdmissionConfig {
8665    /// The partition shares.
8666    ///
8667    /// # Errors
8668    ///
8669    /// Returns an error when a share is out of range or out of order.
8670    pub fn partition_shares(
8671        &self,
8672    ) -> Result<crate::admission::PartitionShares, crate::admission::AdmissionConfigError> {
8673        crate::admission::PartitionShares::new(self.partitions.default, self.partitions.sheddable)
8674    }
8675
8676    /// The adaptive limit bounds. `ceiling` is the static ceiling, if any.
8677    ///
8678    /// When `max_limit` is unset, the ceiling is the maximum. A ceiling
8679    /// below `min_limit` (for example a small capacity contract) lowers the
8680    /// minimum to the ceiling.
8681    ///
8682    /// # Errors
8683    ///
8684    /// Returns an error unless `1 <= min_limit <= max_limit`.
8685    pub fn limit_bounds(
8686        &self,
8687        ceiling: Option<usize>,
8688    ) -> Result<crate::admission::LimitBounds, crate::admission::AdmissionConfigError> {
8689        let (min, max) = match (self.max_limit, ceiling) {
8690            (Some(max), _) => (self.min_limit, max),
8691            (None, Some(ceiling)) if ceiling > 0 => (self.min_limit.min(ceiling), ceiling),
8692            (None, _) => (self.min_limit, DEFAULT_ADMISSION_MAX_LIMIT),
8693        };
8694        crate::admission::LimitBounds::new(min, max, self.initial_limit)
8695    }
8696
8697    /// Check the settings.
8698    ///
8699    /// # Errors
8700    ///
8701    /// Returns [`ConfigError::Validation`] for bad shares or limit bounds.
8702    pub fn validate(&self) -> Result<(), ConfigError> {
8703        self.partition_shares()
8704            .map_err(|e| ConfigError::Validation(e.to_string()))?;
8705        // The static ceiling is not known here; check the explicit bounds.
8706        let max = self.max_limit.unwrap_or(usize::MAX);
8707        crate::admission::LimitBounds::new(self.min_limit, max, self.initial_limit)
8708            .map_err(|e| ConfigError::Validation(e.to_string()))?;
8709        Ok(())
8710    }
8711}
8712
8713/// Direct-HTTPS (native TLS termination) settings (issue #1603).
8714///
8715/// Present under `[server.tls]`; when present the server terminates TLS
8716/// in-process. Both paths point at PEM files: `cert_path` at the leaf
8717/// certificate followed by any intermediates, `key_path` at the matching
8718/// private key (PKCS#8, PKCS#1, or SEC1).
8719///
8720/// # `autumn.toml` example
8721///
8722/// ```toml
8723/// [server.tls]
8724/// cert_path = "/etc/autumn/tls/fullchain.pem"
8725/// key_path = "/etc/autumn/tls/privkey.pem"
8726/// # optional; how often (seconds) to poll for a renewed cert. Default: 60.
8727/// reload_interval_secs = 60
8728/// # optional; per-handshake timeout (seconds). Default: 10.
8729/// handshake_timeout_secs = 10
8730/// ```
8731#[derive(Debug, Clone, Deserialize)]
8732pub struct TlsConfig {
8733    /// Path to the PEM certificate chain (leaf first, then intermediates).
8734    ///
8735    /// Optional so a `[server.tls]` section can instead enable automatic ACME
8736    /// provisioning (issue #1608) via [`acme`](Self::acme). In static-cert mode
8737    /// this must be set together with [`key_path`](Self::key_path); in ACME mode
8738    /// both must be unset. The startup [`validate`](Self::validate) guard
8739    /// rejects any other combination.
8740    #[serde(default)]
8741    pub cert_path: Option<PathBuf>,
8742
8743    /// Path to the PEM private key matching the leaf certificate. Optional; see
8744    /// [`cert_path`](Self::cert_path).
8745    #[serde(default)]
8746    pub key_path: Option<PathBuf>,
8747
8748    /// How often, in seconds, the running server polls the certificate and key
8749    /// files' modification times to pick up an external renewal (e.g. after
8750    /// `certbot`/ACME writes new files) without a restart. Default: `60`.
8751    #[serde(default = "default_tls_reload_interval_secs")]
8752    pub reload_interval_secs: u64,
8753
8754    /// Maximum time, in seconds, allowed for a single inbound TLS handshake
8755    /// before the connection is dropped. Bounds a client that opens TCP but
8756    /// never completes (or starts) the handshake so it cannot park the accept
8757    /// loop and deny service to everyone else. Default: `10`. A value of `0` is
8758    /// clamped to `1` second.
8759    #[serde(default = "default_tls_handshake_timeout_secs")]
8760    pub handshake_timeout_secs: u64,
8761
8762    /// Automatic ACME (Let's Encrypt) certificate provisioning + renewal
8763    /// (issue #1608). When present the server obtains and auto-renews its own
8764    /// certificate over the ACME HTTP-01 challenge instead of loading a static
8765    /// cert from disk. Mutually exclusive with
8766    /// [`cert_path`](Self::cert_path)/[`key_path`](Self::key_path); the startup
8767    /// [`validate`](Self::validate) guard enforces exactly one mode. The serving
8768    /// code is gated behind the off-by-default `acme` feature.
8769    #[serde(default)]
8770    pub acme: Option<AcmeConfig>,
8771
8772    /// Mutual-TLS client-certificate verification (issue #1640). When present
8773    /// with a mode other than [`ClientAuthMode::Off`], the listener requests —
8774    /// and verifies — a client certificate against the configured CA bundle.
8775    /// Absent (the default), the handshake is byte-for-byte #1603's
8776    /// server-only TLS. The serving code is gated behind the same
8777    /// off-by-default `tls` feature as the listener.
8778    #[serde(default)]
8779    pub client_auth: Option<ClientAuthConfig>,
8780}
8781
8782impl TlsConfig {
8783    /// Whether the listener should request a client certificate — i.e. a
8784    /// `[server.tls.client_auth]` section is present with a mode other than
8785    /// `off`.
8786    ///
8787    /// The bind path keys the whole mTLS wiring on this, so an absent (or
8788    /// `off`) section takes the identical #1603 code path.
8789    #[must_use]
8790    pub fn client_auth_active(&self) -> bool {
8791        self.client_auth
8792            .as_ref()
8793            .is_some_and(|c| c.mode.requests_certificate())
8794    }
8795    /// An empty `TlsConfig` used only as the seed for env-var overrides of a
8796    /// section that was absent from TOML. Both paths are unset (which fails
8797    /// fast at startup if neither a static cert nor ACME is configured), and the
8798    /// reload interval takes its default.
8799    const fn empty_for_env() -> Self {
8800        Self {
8801            cert_path: None,
8802            key_path: None,
8803            reload_interval_secs: default_tls_reload_interval_secs(),
8804            handshake_timeout_secs: default_tls_handshake_timeout_secs(),
8805            acme: None,
8806            client_auth: None,
8807        }
8808    }
8809
8810    /// Validate the `[server.tls]` wiring before the listener binds.
8811    ///
8812    /// Exactly one provisioning mode must be selected:
8813    /// - **static cert**: both [`cert_path`](Self::cert_path) and
8814    ///   [`key_path`](Self::key_path) set (and no `[server.tls.acme]`), or
8815    /// - **ACME**: `[server.tls.acme]` present (and neither path set).
8816    ///
8817    /// Every rejection names the offending combination so the operator can act
8818    /// on it without guesswork.
8819    ///
8820    /// # Errors
8821    ///
8822    /// Returns a message describing the first problem found.
8823    pub fn validate(&self) -> Result<(), String> {
8824        let has_cert = self.cert_path.is_some();
8825        let has_key = self.key_path.is_some();
8826        let static_configured = has_cert || has_key;
8827        let acme_configured = self.acme.is_some();
8828
8829        match (static_configured, acme_configured) {
8830            (true, true) => {
8831                return Err(
8832                    "[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; \
8833                     choose exactly one — remove the static cert to use ACME, or remove \
8834                     [server.tls.acme] to serve the static certificate"
8835                        .to_owned(),
8836                );
8837            }
8838            (false, false) => {
8839                return Err(
8840                    "[server.tls] must configure exactly one of: a static certificate \
8841                     (cert_path AND key_path) or automatic provisioning ([server.tls.acme] \
8842                     with domains + contact_email)"
8843                        .to_owned(),
8844                );
8845            }
8846            (true, false) => {
8847                if !(has_cert && has_key) {
8848                    return Err("[server.tls] cert_path and key_path must be set together; \
8849                         set both, or configure [server.tls.acme] instead"
8850                        .to_owned());
8851                }
8852            }
8853            (false, true) => {}
8854        }
8855
8856        if let Some(acme) = &self.acme {
8857            acme.validate()?;
8858        }
8859
8860        if let Some(client_auth) = &self.client_auth {
8861            client_auth.validate()?;
8862        }
8863
8864        Ok(())
8865    }
8866}
8867
8868/// How hard the listener asks for a client certificate (issue #1640).
8869///
8870/// Per listener; a route can additionally demand one via
8871/// [`ClientAuthConfig::required_paths`] or the `RequireClientCert` layer.
8872#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8873#[serde(rename_all = "lowercase")]
8874#[non_exhaustive]
8875pub enum ClientAuthMode {
8876    /// No certificate is requested. The handshake is #1603's server-only TLS.
8877    #[default]
8878    Off,
8879    /// A certificate is requested but not demanded. A client that presents one
8880    /// must pass verification; a client that presents none still connects, and
8881    /// routes that require mTLS reject it.
8882    Optional,
8883    /// A valid certificate is demanded. The handshake fails without one.
8884    Required,
8885}
8886
8887impl ClientAuthMode {
8888    /// Whether the listener asks the client for a certificate at all.
8889    #[must_use]
8890    pub const fn requests_certificate(self) -> bool {
8891        !matches!(self, Self::Off)
8892    }
8893
8894    /// Whether a missing certificate fails the handshake.
8895    #[must_use]
8896    pub const fn mandates_certificate(self) -> bool {
8897        matches!(self, Self::Required)
8898    }
8899
8900    /// The lowercase tag used in config, logs, and the posture manifest.
8901    #[must_use]
8902    pub const fn as_str(self) -> &'static str {
8903        match self {
8904            Self::Off => "off",
8905            Self::Optional => "optional",
8906            Self::Required => "required",
8907        }
8908    }
8909}
8910
8911/// Mutual-TLS client-certificate verification (issue #1640). Present under
8912/// `[server.tls.client_auth]`.
8913///
8914/// The trust store is a PEM bundle of one or more client CAs. It hot-reloads on
8915/// the same poll the server certificate uses, so a CA rotation — ship old+new in
8916/// one bundle, later drop old — needs no restart and drops no connection.
8917///
8918/// # `autumn.toml` example
8919///
8920/// ```toml
8921/// [server.tls.client_auth]
8922/// mode = "required"                                  # off (default) | optional | required
8923/// ca_bundle_path = "/etc/autumn/tls/client-ca.pem"   # one or more PEM CAs
8924/// crl_path = "/etc/autumn/tls/client-ca.crl.pem"     # optional; hot-reloads too
8925/// required_paths = ["/internal/"]                    # routes that demand a certificate
8926/// reload_interval_secs = 60
8927/// ```
8928///
8929/// Unlike `cert_path`/`key_path`, these keys have no `AUTUMN_SERVER__TLS__*`
8930/// env-var override — matching the sibling `[server.tls.acme]` sub-table.
8931#[derive(Debug, Clone, Default, Deserialize)]
8932pub struct ClientAuthConfig {
8933    /// Listener-wide requirement level. Default: `off`.
8934    #[serde(default)]
8935    pub mode: ClientAuthMode,
8936
8937    /// Path to the PEM bundle of trusted client CAs. Required whenever
8938    /// [`mode`](Self::mode) is not `off`. A bundle that is missing, unparseable,
8939    /// or contains no CERTIFICATE block fails startup.
8940    #[serde(default)]
8941    pub ca_bundle_path: Option<PathBuf>,
8942
8943    /// Optional path to a PEM certificate revocation list issued by a CA in the
8944    /// bundle. Revocation is checked for the presented client certificate only;
8945    /// see the deployment guide for the recommended short-lived-certificate
8946    /// posture. OCSP is not supported.
8947    #[serde(default)]
8948    pub crl_path: Option<PathBuf>,
8949
8950    /// Rooted path prefixes whose routes demand a verified client certificate,
8951    /// matched against the normalized request path exactly as CSRF exemptions
8952    /// are. A request reaching one of these over a connection with no verified
8953    /// certificate is rejected with `403` and the standard JSON error envelope.
8954    #[serde(default)]
8955    pub required_paths: Vec<String>,
8956
8957    /// How often, in seconds, to poll the CA bundle and CRL modification times
8958    /// for a rotation. Default: `60`. A value of `0` is clamped to `1` second.
8959    #[serde(default = "default_tls_reload_interval_secs")]
8960    pub reload_interval_secs: u64,
8961}
8962
8963impl ClientAuthConfig {
8964    /// Validate the `[server.tls.client_auth]` wiring before the listener binds.
8965    ///
8966    /// # Errors
8967    ///
8968    /// Returns a message describing the first problem found.
8969    pub fn validate(&self) -> Result<(), String> {
8970        if self.mode.requests_certificate() {
8971            match &self.ca_bundle_path {
8972                None => {
8973                    return Err(format!(
8974                        "[server.tls.client_auth] mode = \"{}\" needs ca_bundle_path — the PEM \
8975                         bundle of client CAs to verify against",
8976                        self.mode.as_str()
8977                    ));
8978                }
8979                Some(path) if path.to_str().is_none_or(|p| p.trim().is_empty()) => {
8980                    return Err(
8981                        "[server.tls.client_auth] ca_bundle_path is empty; set it to the PEM \
8982                         bundle of client CAs, or remove the section"
8983                            .to_owned(),
8984                    );
8985                }
8986                Some(_) => {}
8987            }
8988        } else if !self.required_paths.is_empty() {
8989            return Err(
8990                "[server.tls.client_auth] lists required_paths but mode = \"off\", so no \
8991                 certificate is ever requested and those routes would reject every request; \
8992                 set mode = \"optional\" or \"required\""
8993                    .to_owned(),
8994            );
8995        }
8996
8997        if self
8998            .crl_path
8999            .as_ref()
9000            .is_some_and(|p| p.to_str().is_none_or(|s| s.trim().is_empty()))
9001        {
9002            return Err(
9003                "[server.tls.client_auth] crl_path is empty; set it to a PEM revocation list, \
9004                 or remove the key"
9005                    .to_owned(),
9006            );
9007        }
9008
9009        for path in &self.required_paths {
9010            if !path.starts_with('/') {
9011                return Err(format!(
9012                    "[server.tls.client_auth] required_paths entry `{path}` must be a rooted \
9013                     path prefix starting with `/`"
9014                ));
9015            }
9016            // Requests are normalized before matching (`security::path::clean_path`)
9017            // but the configured prefix is not, so a noncanonical prefix silently
9018            // matches less than it appears to: `/internal//` reduces to
9019            // `/internal/` after the matcher strips ONE trailing slash, and
9020            // `/internal/keys` then fails to match it. A prefix that protects
9021            // less than the operator wrote is the failure this whole section
9022            // exists to prevent, so refuse it rather than normalize it silently.
9023            if path.contains("//") {
9024                return Err(format!(
9025                    "[server.tls.client_auth] required_paths entry `{path}` contains an empty \
9026                     path segment; write it as a canonical prefix like `/internal/`"
9027                ));
9028            }
9029            if path
9030                .split('/')
9031                .any(|segment| segment == "." || segment == "..")
9032            {
9033                return Err(format!(
9034                    "[server.tls.client_auth] required_paths entry `{path}` contains a dot \
9035                     segment; write the resolved prefix instead"
9036                ));
9037            }
9038        }
9039
9040        Ok(())
9041    }
9042}
9043
9044/// Automatic ACME (Let's Encrypt) certificate provisioning settings (issue
9045/// #1608). Present under `[server.tls.acme]`.
9046///
9047/// # Deployment scope
9048///
9049/// HTTP-01 ACME here is **single-host**: the challenge-token map is per-process
9050/// in-memory and certificates are stored on local disk, so behind a load
9051/// balancer the CA's `:80` validation can hit a replica without the token
9052/// (→ 404) and non-leader replicas cannot adopt a cert from a non-shared store.
9053/// Single-replica deployments are fully correct; multi-replica needs a shared
9054/// token store or DNS-01 (tracked in #1620). Configuring ACME alongside a
9055/// distributed scheduler backend logs a startup warning.
9056///
9057/// # `autumn.toml` example
9058///
9059/// ```toml
9060/// [server.tls.acme]
9061/// domains = ["app.example.com"]
9062/// contact_email = "ops@example.com"
9063/// # optional; "staging" (default), "production", or a custom directory URL.
9064/// directory = "staging"
9065/// ```
9066#[derive(Debug, Clone, Deserialize)]
9067pub struct AcmeConfig {
9068    /// Domains to include on the issued certificate (SANs). At least one is
9069    /// required. Wildcards (`*.example.com`) are rejected — they require the
9070    /// DNS-01 challenge, tracked in issue #1620.
9071    pub domains: Vec<String>,
9072
9073    /// Contact email registered with the ACME account (used for expiry
9074    /// notifications from the CA). Required.
9075    pub contact_email: String,
9076
9077    /// Which ACME directory to use. Defaults to Let's Encrypt **staging** on
9078    /// purpose, so a first run or CI cannot burn the strict production rate
9079    /// limit before the deployment is known good.
9080    #[serde(default)]
9081    pub directory: AcmeDirectory,
9082
9083    /// Directory that stores the ACME account key and issued certificates.
9084    /// Default: `config/acme`.
9085    #[serde(default = "default_acme_cache_dir")]
9086    pub cache_dir: PathBuf,
9087
9088    /// Port to serve the HTTP-01 challenge (and the HTTP→HTTPS redirect) on.
9089    /// The ACME CA always validates HTTP-01 over port 80, so this defaults to
9090    /// `80`; override it when a front-end forwards `:80` to another port.
9091    #[serde(default = "default_acme_http_challenge_port")]
9092    pub http_challenge_port: u16,
9093
9094    /// Renew the certificate once it has fewer than this many days of validity
9095    /// left. Default: `30`.
9096    #[serde(default = "default_acme_renew_before_days")]
9097    pub renew_before_days: u32,
9098
9099    /// PEM file holding the root certificate that signs the ACME **directory's
9100    /// own HTTPS certificate**, for a directory that is not publicly trusted.
9101    ///
9102    /// The ACME client speaks HTTPS to the directory and, by default, verifies
9103    /// it against the **platform trust store** (the host's own installed CA
9104    /// certificates). That is right for Let's Encrypt — both its staging and its
9105    /// production API endpoints carry publicly-trusted certificates — and wrong
9106    /// for a private CA or a [Pebble](https://github.com/letsencrypt/pebble)
9107    /// test server whose API certificate chains to a root the host does not
9108    /// know. Point this at that root and the client trusts it *instead of* the
9109    /// platform store.
9110    ///
9111    /// Only the **first** certificate in the file becomes a trust anchor, so
9112    /// give it the root alone rather than a leaf/intermediate bundle. Setting it
9113    /// also swaps the platform verifier for a plain path verifier, which on
9114    /// macOS and Windows means the OS-level policy and revocation checks no
9115    /// longer apply to this one connection.
9116    ///
9117    /// Unset by default, and unnecessary for `directory = "staging"` /
9118    /// `"production"`. This changes trust for the **ACME control plane only**;
9119    /// it has no bearing on which certificates browsers accept from your site.
9120    ///
9121    /// Like every other `[server.tls.acme]` key, this has no
9122    /// `AUTUMN_SERVER__TLS__ACME__*` environment override: the section is
9123    /// configured in `autumn.toml` as a unit. Deliberate, and worth knowing if
9124    /// the root arrives as a container mount — the path still has to be named in
9125    /// the config file.
9126    #[serde(default)]
9127    pub ca_root_path: Option<PathBuf>,
9128
9129    /// DNS-01 challenge settings (issue #1620). Present under
9130    /// `[server.tls.acme.dns]`.
9131    ///
9132    /// When set, every authorization in an order is answered over **DNS-01**
9133    /// instead of HTTP-01, which is the only challenge type a CA will accept for
9134    /// a **wildcard** identifier — so `domains` may list `*.example.com`. When
9135    /// absent, issuance stays on #1608's HTTP-01 path and wildcards are rejected.
9136    #[serde(default)]
9137    pub dns: Option<AcmeDnsConfig>,
9138
9139    /// Tenant custom domains (issue #1635). Present under
9140    /// `[server.tls.acme.custom_domains]`.
9141    ///
9142    /// When enabled, tenants connect their own hostnames, each getting its own
9143    /// verified, per-domain certificate served by SNI. Absent means no tenant
9144    /// hostname is registrable and SNI selection is unchanged.
9145    #[serde(default)]
9146    pub custom_domains: Option<CustomDomainsConfig>,
9147}
9148
9149/// `[server.tls.acme.custom_domains]` — tenant-connected hostnames with
9150/// per-domain ACME certificates (issue #1635).
9151///
9152/// The whole feature is config-only: no per-domain entries ever appear here.
9153/// Domains are registered at runtime through
9154/// [`CustomDomainRegistry`](crate::custom_domain::CustomDomainRegistry), which
9155/// is what makes a 1,000-tenant deployment a fixed twenty lines of config.
9156#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9157#[serde(deny_unknown_fields)]
9158pub struct CustomDomainsConfig {
9159    /// Turn the feature on. Off by default: enabling it opens an ACME order
9160    /// path driven by tenant-supplied hostnames.
9161    #[serde(default)]
9162    pub enabled: bool,
9163
9164    /// The ingress hostname tenants CNAME their subdomains at.
9165    #[serde(default)]
9166    pub ingress_hostname: Option<String>,
9167
9168    /// The ingress IPv4 addresses tenants point apex domains at with A records.
9169    ///
9170    /// Held as strings and parsed by [`validate`](Self::validate), matching
9171    /// `[server.tls.acme.dns] resolvers`: a typed address here aborts the
9172    /// config schema walk that `autumn doctor` and strict-config checking
9173    /// build on.
9174    #[serde(default)]
9175    pub ingress_ipv4: Vec<String>,
9176
9177    /// The ingress IPv6 addresses, for AAAA records.
9178    #[serde(default)]
9179    pub ingress_ipv6: Vec<String>,
9180
9181    /// Directory holding the domain registry. Default: `config/acme/domains`.
9182    #[serde(default = "default_custom_domains_dir")]
9183    pub store_dir: PathBuf,
9184
9185    /// Most domains this deployment will accept. Default: `1000`.
9186    #[serde(default = "default_custom_domains_max")]
9187    pub max_domains: usize,
9188
9189    /// Certificates held in memory at once. Beyond this, a handshake for a
9190    /// cold domain re-reads its certificate from the store. Default: `256`.
9191    #[serde(default = "default_custom_domains_cert_cache")]
9192    pub cert_cache_size: usize,
9193
9194    /// ACME orders allowed per domain per day. Default: `5`.
9195    #[serde(default = "default_custom_domains_per_domain_per_day")]
9196    pub issuance_per_domain_per_day: u32,
9197
9198    /// ACME orders allowed across all domains per hour. Default: `50`.
9199    #[serde(default = "default_custom_domains_global_per_hour")]
9200    pub issuance_global_per_hour: u32,
9201
9202    /// First retry delay after a failure; doubles per consecutive failure.
9203    /// Default: `300` (5 minutes).
9204    #[serde(default = "default_custom_domains_base_backoff")]
9205    pub failure_backoff_secs: u64,
9206
9207    /// Cap on the doubling backoff. Default: `86400` (a day).
9208    #[serde(default = "default_custom_domains_max_backoff")]
9209    pub max_failure_backoff_secs: u64,
9210
9211    /// How often the orchestrator verifies, issues and renews. Default: `60`.
9212    #[serde(default = "default_custom_domains_poll_secs")]
9213    pub poll_interval_secs: u64,
9214
9215    /// Recursive resolvers used to read each domain's ownership TXT record
9216    /// and to find its zone's authoritative nameservers. Each entry is `IP`
9217    /// (port 53 implied) or `IP:port`. Default: Cloudflare and Google public
9218    /// DNS.
9219    #[serde(default = "default_acme_dns_resolvers")]
9220    pub resolvers: Vec<String>,
9221}
9222
9223impl Default for CustomDomainsConfig {
9224    fn default() -> Self {
9225        Self {
9226            enabled: false,
9227            ingress_hostname: None,
9228            ingress_ipv4: Vec::new(),
9229            ingress_ipv6: Vec::new(),
9230            store_dir: default_custom_domains_dir(),
9231            max_domains: default_custom_domains_max(),
9232            cert_cache_size: default_custom_domains_cert_cache(),
9233            issuance_per_domain_per_day: default_custom_domains_per_domain_per_day(),
9234            issuance_global_per_hour: default_custom_domains_global_per_hour(),
9235            failure_backoff_secs: default_custom_domains_base_backoff(),
9236            max_failure_backoff_secs: default_custom_domains_max_backoff(),
9237            poll_interval_secs: default_custom_domains_poll_secs(),
9238            resolvers: default_acme_dns_resolvers(),
9239        }
9240    }
9241}
9242
9243impl CustomDomainsConfig {
9244    /// Parse [`resolvers`](Self::resolvers) into socket addresses, defaulting a
9245    /// bare IP to port 53.
9246    ///
9247    /// # Errors
9248    ///
9249    /// Returns a message naming the first unparseable entry.
9250    pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
9251        self.resolvers
9252            .iter()
9253            .map(|entry| parse_resolver_addr("[server.tls.acme.custom_domains]", entry))
9254            .collect()
9255    }
9256
9257    /// A scheme, a port or a path in `ingress_hostname` reaches tenants
9258    /// verbatim as their CNAME target. The record would be invalid, the
9259    /// ingress would never resolve, and every subdomain domain would sit at
9260    /// `pending_dns` with nothing to explain why.
9261    fn validate_ingress_hostname(&self) -> Result<(), String> {
9262        if let Some(raw) = self
9263            .ingress_hostname
9264            .as_ref()
9265            .filter(|h| !h.trim().is_empty())
9266        {
9267            let canonical = raw.trim().trim_end_matches('.').to_ascii_lowercase();
9268            match crate::custom_domain::normalize_hostname(raw) {
9269                Ok(host) if host == canonical => {}
9270                Ok(host) => {
9271                    return Err(format!(
9272                        "[server.tls.acme.custom_domains] ingress_hostname `{raw}` must be a bare \
9273                     DNS name (`{host}`): a scheme, port or path cannot be a CNAME target, so \
9274                     tenants would be given a record that never resolves"
9275                    ));
9276                }
9277                Err(e) => {
9278                    return Err(format!(
9279                        "[server.tls.acme.custom_domains] ingress_hostname `{raw}` is not a usable \
9280                     DNS name: {e}. Tenants are given it as their CNAME target, so every \
9281                     subdomain would stay at pending_dns"
9282                    ));
9283                }
9284            }
9285        }
9286        Ok(())
9287    }
9288
9289    /// Validate the custom-domain wiring.
9290    ///
9291    /// # Errors
9292    ///
9293    /// Returns a message describing the first problem found.
9294    pub fn validate(&self) -> Result<(), String> {
9295        if !self.enabled {
9296            return Ok(());
9297        }
9298        if self
9299            .ingress_hostname
9300            .as_ref()
9301            .is_none_or(|h| h.trim().is_empty())
9302            && self.ingress_ipv4.is_empty()
9303            && self.ingress_ipv6.is_empty()
9304        {
9305            return Err(
9306                "[server.tls.acme.custom_domains] needs somewhere for tenants to point DNS: set \
9307                 ingress_hostname (the CNAME target for tenant subdomains) and/or ingress_ipv4 / \
9308                 ingress_ipv6 (the A/AAAA records apex domains need). Without one, no tenant can \
9309                 be given usable DNS instructions and no domain can ever verify"
9310                    .to_owned(),
9311            );
9312        }
9313        self.validate_ingress_hostname()?;
9314        for (key, values) in [
9315            ("ingress_ipv4", &self.ingress_ipv4),
9316            ("ingress_ipv6", &self.ingress_ipv6),
9317        ] {
9318            for value in values {
9319                let parsed = if key == "ingress_ipv4" {
9320                    value.trim().parse::<std::net::Ipv4Addr>().is_ok()
9321                } else {
9322                    value.trim().parse::<std::net::Ipv6Addr>().is_ok()
9323                };
9324                if !parsed {
9325                    return Err(format!(
9326                        "[server.tls.acme.custom_domains] {key} entry `{value}` is not a valid \
9327                         address; a tenant would be given a DNS record that points nowhere"
9328                    ));
9329                }
9330            }
9331        }
9332        if self.max_domains == 0 {
9333            return Err(
9334                "[server.tls.acme.custom_domains] max_domains must be at least 1; 0 rejects every \
9335                 registration"
9336                    .to_owned(),
9337            );
9338        }
9339        if self.cert_cache_size == 0 {
9340            return Err(
9341                "[server.tls.acme.custom_domains] cert_cache_size must be at least 1: a zero-sized \
9342                 cache would re-read every certificate on every handshake"
9343                    .to_owned(),
9344            );
9345        }
9346        if self.issuance_per_domain_per_day == 0 || self.issuance_global_per_hour == 0 {
9347            return Err(
9348                "[server.tls.acme.custom_domains] issuance_per_domain_per_day and \
9349                 issuance_global_per_hour must both be at least 1; 0 refuses every order and no \
9350                 domain can ever become active"
9351                    .to_owned(),
9352            );
9353        }
9354        if self.failure_backoff_secs == 0 {
9355            return Err(
9356                "[server.tls.acme.custom_domains] failure_backoff_secs must be at least 1: a zero \
9357                 backoff retries a permanently broken domain every tick and burns the CA's rate \
9358                 limits"
9359                    .to_owned(),
9360            );
9361        }
9362        if self.max_failure_backoff_secs < self.failure_backoff_secs {
9363            return Err(format!(
9364                "[server.tls.acme.custom_domains] max_failure_backoff_secs ({}) must be at least \
9365                 failure_backoff_secs ({}): the cap is applied to the doubling delay, so a smaller \
9366                 cap silently disables the backoff",
9367                self.max_failure_backoff_secs, self.failure_backoff_secs
9368            ));
9369        }
9370        if self.poll_interval_secs == 0 {
9371            return Err(
9372                "[server.tls.acme.custom_domains] poll_interval_secs must be at least 1; 0 spins \
9373                 the orchestrator"
9374                    .to_owned(),
9375            );
9376        }
9377        if self.resolvers.is_empty() {
9378            return Err(
9379                "[server.tls.acme.custom_domains] resolvers must list at least one DNS resolver: \
9380                 without one no ownership TXT record can be read, so no domain can verify"
9381                    .to_owned(),
9382            );
9383        }
9384        self.resolver_addrs()?;
9385        Ok(())
9386    }
9387
9388    /// The ingress this deployment tells tenants to point at.
9389    ///
9390    /// A hostname or address that does not parse is dropped here;
9391    /// [`validate`](Self::validate) has already refused the configuration, so
9392    /// the app never reaches this with a bad value.
9393    #[must_use]
9394    pub fn ingress(&self) -> crate::custom_domain::ExpectedIngress {
9395        crate::custom_domain::ExpectedIngress {
9396            hostname: self
9397                .ingress_hostname
9398                .as_ref()
9399                .and_then(|h| crate::custom_domain::normalize_hostname(h).ok()),
9400            ipv4: self
9401                .ingress_ipv4
9402                .iter()
9403                .filter_map(|a| a.trim().parse().ok())
9404                .collect(),
9405            ipv6: self
9406                .ingress_ipv6
9407                .iter()
9408                .filter_map(|a| a.trim().parse().ok())
9409                .collect(),
9410        }
9411    }
9412}
9413
9414impl AcmeConfig {
9415    /// Validate the ACME wiring: at least one non-wildcard domain and a
9416    /// non-empty contact email.
9417    ///
9418    /// # Errors
9419    ///
9420    /// Returns a message describing the first problem found.
9421    pub fn validate(&self) -> Result<(), String> {
9422        if self.domains.is_empty() {
9423            return Err(
9424                "[server.tls.acme] domains must list at least one domain to request a \
9425                 certificate for"
9426                    .to_owned(),
9427            );
9428        }
9429        if self.contact_email.trim().is_empty() {
9430            return Err(
9431                "[server.tls.acme] contact_email must be set (the ACME CA requires an account \
9432                 contact for expiry notifications)"
9433                    .to_owned(),
9434            );
9435        }
9436        if self.http_challenge_port == 0 {
9437            return Err(
9438                "[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
9439                 OS-assigned port that the ACME HTTP-01 validator (which always connects on port \
9440                 80) can never reach, so every issuance fails. Use 80, or the port a front-end \
9441                 forwards `:80` to"
9442                    .to_owned(),
9443            );
9444        }
9445        // The renew-before window is compared against the issued certificate's
9446        // REMAINING validity. Publicly-trusted CAs (Let's Encrypt) issue
9447        // ~90-day certificates, so treat 90 days as the effective maximum cert
9448        // lifetime: a `renew_before_days >= 90` keeps the freshly-issued
9449        // certificate perpetually inside its renew-before window, so `needs_renewal`
9450        // stays true immediately after every successful renewal and the hourly
9451        // loop orders a brand-new certificate every tick until the CA's rate
9452        // limits are hit. Reject it up front.
9453        if self.renew_before_days >= 90 {
9454            return Err(format!(
9455                "[server.tls.acme] renew_before_days ({}) must be less than 90: it is compared \
9456                 against the issued certificate's remaining validity, and publicly-trusted CAs \
9457                 (e.g. Let's Encrypt) issue certificates that live at most ~90 days. A value >= \
9458                 the certificate lifetime keeps the cert perpetually inside its renew-before \
9459                 window, so the renewal loop would order a fresh certificate every hour and burn \
9460                 the CA's rate limits. Use a smaller value (default 30)",
9461                self.renew_before_days
9462            ));
9463        }
9464        if let Some(path) = &self.ca_root_path
9465            && path.to_str().is_none_or(|p| p.trim().is_empty())
9466        {
9467            return Err(
9468                "[server.tls.acme] ca_root_path is set but blank: either remove it (to use the \
9469                 platform trust store, which is correct for Let's Encrypt staging and \
9470                 production) or point it at the PEM root that signs your ACME directory's HTTPS \
9471                 certificate"
9472                    .to_owned(),
9473            );
9474        }
9475        for (index, domain) in self.domains.iter().enumerate() {
9476            let trimmed = domain.trim();
9477            if trimmed.is_empty() {
9478                return Err(format!(
9479                    "[server.tls.acme] domains must not contain blank entries (entry at index \
9480                     {index} is empty or whitespace-only)"
9481                ));
9482            }
9483            // A wildcard identifier can only ever be validated over DNS-01, so it
9484            // is accepted exactly when `[server.tls.acme.dns]` is configured —
9485            // and its shape is checked here rather than surfacing later as an
9486            // opaque CA rejection mid-issuance.
9487            if trimmed.contains('*') {
9488                if self.dns.is_none() {
9489                    return Err(format!(
9490                        "[server.tls.acme] wildcard domain `{trimmed}` needs the DNS-01 \
9491                         challenge: an ACME CA will not validate a wildcard identifier over \
9492                         HTTP-01. Add a [server.tls.acme.dns] section naming your DNS provider \
9493                         (and the credentials-store key holding its API token), or list explicit \
9494                         hostnames instead"
9495                    ));
9496                }
9497                let Some(base) = trimmed.strip_prefix("*.") else {
9498                    return Err(format!(
9499                        "[server.tls.acme] domain `{trimmed}` is not a usable wildcard: a \
9500                         wildcard SAN must be written as `*.` followed by the base domain (e.g. \
9501                         `*.myapp.com`) — a `*` anywhere else is not matched by any client"
9502                    ));
9503                };
9504                if base.is_empty() || base.contains('*') {
9505                    return Err(format!(
9506                        "[server.tls.acme] domain `{trimmed}` is not a usable wildcard: exactly \
9507                         one leading `*.` is allowed and the base domain after it must be \
9508                         non-empty (e.g. `*.myapp.com`)"
9509                    ));
9510                }
9511            }
9512            // The checks above read `trimmed`, but the entry is stored — and used —
9513            // UNTRIMMED: it becomes the certificate's SAN via `CertificateParams`,
9514            // the placeholder key's `CertId`, and the ACME order's
9515            // `Identifier::Dns`. A padded ` app.example.com ` is therefore a
9516            // different identifier than the hostname the operator meant, and the
9517            // failure surfaces as an opaque CA rejection mid-issuance. Reject the
9518            // padding here instead of silently trimming, so the config file says
9519            // exactly what will be requested.
9520            if domain != trimmed {
9521                return Err(format!(
9522                    "[server.tls.acme] domain `{domain}` (entry at index {index}) has leading or \
9523                     trailing whitespace: the entry is used verbatim as the certificate's SAN and \
9524                     as the ACME order's DNS identifier, so the padded value would be requested \
9525                     as-is. Write it as `{trimmed}`"
9526                ));
9527            }
9528        }
9529        if let Some(dns) = &self.dns {
9530            dns.validate()?;
9531        }
9532        if let Some(custom) = &self.custom_domains {
9533            custom.validate()?;
9534        }
9535        Ok(())
9536    }
9537
9538    /// Whether the configured certificate would cover `host`.
9539    ///
9540    /// Applies RFC 6125 wildcard matching across every entry in
9541    /// [`domains`](Self::domains), so a `*.myapp.com` SAN covers
9542    /// `tenant42.myapp.com` but not the apex and not a deeper label. Used by
9543    /// `autumn doctor` to check a `tenancy.base_domain` against the certificate
9544    /// (issue #1620).
9545    #[must_use]
9546    pub fn covers_host(&self, host: &str) -> bool {
9547        self.domains.iter().any(|san| san_covers_host(san, host))
9548    }
9549}
9550
9551/// DNS-01 challenge settings for wildcard (and multi-replica) ACME issuance
9552/// (issue #1620). Present under `[server.tls.acme.dns]`.
9553///
9554/// # Secrets never live here
9555///
9556/// This section names a **credential reference**, never a token. The provider's
9557/// API credential is read from the encrypted credentials store
9558/// (`autumn credentials edit`, `config/credentials/<env>.toml.enc`) under the
9559/// [`credential`](Self::credential) key, or from the documented environment
9560/// variables. There is deliberately no field to hold a token, and the section is
9561/// `deny_unknown_fields`, so an `api_token = "..."` written into `autumn.toml`
9562/// is a **load-time error** rather than a plaintext secret that silently works.
9563///
9564/// # `autumn.toml` example
9565///
9566/// ```toml
9567/// [server.tls.acme]
9568/// domains = ["myapp.com", "*.myapp.com"]
9569/// contact_email = "ops@myapp.com"
9570/// directory = "production"
9571///
9572/// [server.tls.acme.dns]
9573/// provider = "cloudflare"
9574/// # optional; the credentials-store key holding the token. Default: "acme_dns".
9575/// credential = "acme_dns"
9576/// ```
9577#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9578#[serde(deny_unknown_fields)]
9579pub struct AcmeDnsConfig {
9580    /// Which DNS provider writes the `_acme-challenge` TXT records.
9581    pub provider: AcmeDnsProvider,
9582
9583    /// Key in the encrypted credentials store holding this provider's
9584    /// credential. Default: `acme_dns`. Never the credential itself.
9585    #[serde(default = "default_acme_dns_credential")]
9586    pub credential: String,
9587
9588    /// How long, in seconds, to wait for a published TXT record to become
9589    /// visible on every configured resolver before failing the order.
9590    /// Default: `300`.
9591    #[serde(default = "default_acme_dns_propagation_timeout_secs")]
9592    pub propagation_timeout_secs: u64,
9593
9594    /// Seconds between propagation probes. Default: `5`.
9595    #[serde(default = "default_acme_dns_poll_interval_secs")]
9596    pub poll_interval_secs: u64,
9597
9598    /// Resolvers queried to confirm propagation. Each entry is `IP` (port 53
9599    /// implied) or `IP:port`. Default: Cloudflare and Google public DNS.
9600    #[serde(default = "default_acme_dns_resolvers")]
9601    pub resolvers: Vec<String>,
9602
9603    /// The hook program for [`AcmeDnsProvider::Exec`], as an **argv array**
9604    /// (never a shell string). Autumn appends `present|cleanup`, the record
9605    /// FQDN, and the TXT value as three further arguments, so nothing is
9606    /// interpolated into a shell. Required for `exec`, rejected for every other
9607    /// provider.
9608    #[serde(default)]
9609    pub command: Vec<String>,
9610}
9611
9612impl AcmeDnsConfig {
9613    /// Parse [`resolvers`](Self::resolvers) into socket addresses, defaulting a
9614    /// bare IP to port 53.
9615    ///
9616    /// # Errors
9617    ///
9618    /// Returns a message naming the first unparseable entry.
9619    pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
9620        self.resolvers
9621            .iter()
9622            .map(|entry| parse_resolver_addr("[server.tls.acme.dns]", entry))
9623            .collect()
9624    }
9625
9626    /// Validate the DNS-01 wiring.
9627    ///
9628    /// # Errors
9629    ///
9630    /// Returns a message describing the first problem found.
9631    pub fn validate(&self) -> Result<(), String> {
9632        if self.credential.trim().is_empty() {
9633            return Err(
9634                "[server.tls.acme.dns] credential must name the key in the encrypted \
9635                 credentials store that holds the DNS provider's API credential (run \
9636                 `autumn credentials edit`). It is a key NAME, never the token itself"
9637                    .to_owned(),
9638            );
9639        }
9640        match self.provider {
9641            AcmeDnsProvider::Exec => {
9642                if self
9643                    .command
9644                    .first()
9645                    .is_some_and(|program| !program.trim().is_empty() && program != program.trim())
9646                {
9647                    return Err(format!(
9648                        "[server.tls.acme.dns] command's program `{}` has leading or trailing \
9649                         whitespace: it is passed to the OS verbatim, so the padded value would \
9650                         fail to execute. Write it without the padding",
9651                        self.command[0]
9652                    ));
9653                }
9654                if self.command.first().is_none_or(|p| p.trim().is_empty()) {
9655                    return Err(
9656                        "[server.tls.acme.dns] provider = \"exec\" requires a non-empty `command` \
9657                         argv array (e.g. command = [\"/usr/local/bin/dns-hook\"]); autumn appends \
9658                         `present`/`cleanup`, the record FQDN and the TXT value as further \
9659                         arguments"
9660                            .to_owned(),
9661                    );
9662                }
9663            }
9664            AcmeDnsProvider::Cloudflare | AcmeDnsProvider::Route53 => {
9665                if !self.command.is_empty() {
9666                    return Err(format!(
9667                        "[server.tls.acme.dns] command is set but provider is \"{}\": the command \
9668                         would never run. Remove it, or set provider = \"exec\" to use the \
9669                         external-hook escape hatch",
9670                        self.provider.as_str()
9671                    ));
9672                }
9673            }
9674        }
9675        if self.propagation_timeout_secs == 0 {
9676            return Err(
9677                "[server.tls.acme.dns] propagation_timeout_secs must not be 0: a zero budget \
9678                 fails every order before a freshly-written TXT record could ever be visible. \
9679                 Use the default (300) or a larger value for a slow-propagating zone"
9680                    .to_owned(),
9681            );
9682        }
9683        if self.poll_interval_secs == 0 {
9684            return Err(
9685                "[server.tls.acme.dns] poll_interval_secs must not be 0: it would busy-loop the \
9686                 configured resolvers. Use the default (5)"
9687                    .to_owned(),
9688            );
9689        }
9690        if self.propagation_timeout_secs > MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS {
9691            return Err(format!(
9692                "[server.tls.acme.dns] propagation_timeout_secs ({}) must be at most {}: the wait \
9693                 is computed as an instant `now + timeout`, and an unbounded value overflows that \
9694                 and panics the renewal task, leaving the self-signed placeholder served \
9695                 indefinitely. An hour is far past any real provider's propagation time",
9696                self.propagation_timeout_secs, MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS
9697            ));
9698        }
9699        if self.poll_interval_secs > self.propagation_timeout_secs {
9700            return Err(format!(
9701                "[server.tls.acme.dns] poll_interval_secs ({}) is greater than \
9702                 propagation_timeout_secs ({}): the record would be probed once and the wait \
9703                 would time out without ever re-checking. Lower poll_interval_secs",
9704                self.poll_interval_secs, self.propagation_timeout_secs
9705            ));
9706        }
9707        if self.resolvers.is_empty() {
9708            return Err(
9709                "[server.tls.acme.dns] resolvers must list at least one DNS resolver to confirm \
9710                 TXT propagation against (default: 1.1.1.1:53 and 8.8.8.8:53)"
9711                    .to_owned(),
9712            );
9713        }
9714        self.resolver_addrs()?;
9715        Ok(())
9716    }
9717}
9718
9719/// Parse one `resolvers` entry of `section` into a socket address,
9720/// defaulting a bare IP address to port 53.
9721fn parse_resolver_addr(section: &str, entry: &str) -> Result<std::net::SocketAddr, String> {
9722    let trimmed = entry.trim();
9723    if let Ok(addr) = trimmed.parse::<std::net::SocketAddr>() {
9724        return Ok(addr);
9725    }
9726    if let Ok(ip) = trimmed.parse::<std::net::IpAddr>() {
9727        return Ok(std::net::SocketAddr::new(ip, 53));
9728    }
9729    Err(format!(
9730        "{section} resolvers entry `{entry}` is not a resolver address: write it as an IP \
9731         (`1.1.1.1`, port 53 implied) or `IP:port` (`1.1.1.1:53`). Hostnames are not accepted — \
9732         resolving the resolver would defeat the purpose of the DNS check"
9733    ))
9734}
9735
9736/// Which DNS provider writes the ACME DNS-01 `_acme-challenge` TXT records.
9737///
9738/// The curated set is deliberately small; [`Exec`](Self::Exec) is the documented
9739/// escape hatch for everything else (RFC 2136 via `nsupdate`, a registrar CLI, a
9740/// webhook shim).
9741#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
9742#[serde(rename_all = "snake_case")]
9743#[non_exhaustive]
9744pub enum AcmeDnsProvider {
9745    /// Cloudflare DNS, via the v4 REST API with a scoped API token.
9746    Cloudflare,
9747    /// Amazon Route 53, via `ChangeResourceRecordSets` with `SigV4` credentials.
9748    Route53,
9749    /// An operator-provided hook program — the escape hatch for any other
9750    /// provider.
9751    Exec,
9752}
9753
9754impl AcmeDnsProvider {
9755    /// The provider's stable config spelling (also used in health details).
9756    #[must_use]
9757    pub const fn as_str(self) -> &'static str {
9758        match self {
9759            Self::Cloudflare => "cloudflare",
9760            Self::Route53 => "route53",
9761            Self::Exec => "exec",
9762        }
9763    }
9764}
9765
9766impl std::fmt::Display for AcmeDnsProvider {
9767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9768        f.write_str(self.as_str())
9769    }
9770}
9771
9772/// Whether a certificate SAN entry covers `host`, using RFC 6125 wildcard rules.
9773///
9774/// A `*.example.com` SAN covers exactly one label — `tenant1.example.com` but
9775/// neither `a.b.example.com` nor the bare apex `example.com`. Matching is
9776/// case-insensitive and tolerates a trailing dot on `host`.
9777///
9778/// Used by `autumn doctor` to tell an operator that their `tenancy.base_domain`
9779/// is not covered by the configured certificate (issue #1620).
9780#[must_use]
9781pub fn san_covers_host(san: &str, host: &str) -> bool {
9782    let san = san.trim().trim_end_matches('.').to_ascii_lowercase();
9783    let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
9784    if san.is_empty() || host.is_empty() {
9785        return false;
9786    }
9787    let Some(suffix) = san.strip_prefix("*.") else {
9788        return san == host;
9789    };
9790    if suffix.is_empty() {
9791        return false;
9792    }
9793    // Exactly one label may stand in for the `*`: strip the suffix and require
9794    // what remains to be a single non-empty, dot-free label.
9795    let Some(label) = host
9796        .strip_suffix(&suffix)
9797        .and_then(|prefix| prefix.strip_suffix('.'))
9798    else {
9799        return false;
9800    };
9801    !label.is_empty() && !label.contains('.')
9802}
9803
9804/// Which ACME directory endpoint to provision against.
9805#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
9806#[serde(rename_all = "snake_case")]
9807pub enum AcmeDirectory {
9808    /// Let's Encrypt **staging** (the default): untrusted certificates, but
9809    /// generous rate limits — safe for first runs and CI.
9810    #[default]
9811    Staging,
9812    /// Let's Encrypt production: trusted certificates, strict rate limits.
9813    Production,
9814    /// A custom ACME directory URL (e.g. a private CA or a pebble test server).
9815    Custom {
9816        /// The directory URL (e.g. `https://acme.example.com/directory`).
9817        url: String,
9818    },
9819}
9820
9821/// Behavior when a configured read replica is unavailable or stale.
9822#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
9823#[serde(rename_all = "snake_case")]
9824#[non_exhaustive]
9825pub enum ReplicaFallback {
9826    /// Readiness should fail when the configured replica cannot safely serve reads.
9827    #[default]
9828    FailReadiness,
9829    /// Read paths may use the primary when the replica is unavailable or stale.
9830    Primary,
9831}
9832
9833impl std::str::FromStr for ReplicaFallback {
9834    type Err = ();
9835
9836    fn from_str(value: &str) -> Result<Self, Self::Err> {
9837        match value.trim().to_ascii_lowercase().as_str() {
9838            "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
9839            "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
9840            _ => Err(()),
9841        }
9842    }
9843}
9844
9845/// Strategy for routing reads that follow a write within the same request or
9846/// client session.
9847///
9848/// Replication is asynchronous: a read immediately after a write can land on a
9849/// lagging replica and return stale data (the read-your-own-writes anomaly).
9850/// This setting lets Autumn pin such reads to the primary.
9851///
9852/// Configured via `database.read_your_writes` in `autumn.toml` or
9853/// `AUTUMN_DATABASE__READ_YOUR_WRITES` in the environment.
9854///
9855/// Default: `off` (preserves today's behavior — no post-write pinning).
9856#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
9857#[serde(rename_all = "snake_case")]
9858#[non_exhaustive]
9859pub enum ReadYourWrites {
9860    /// No post-write read pinning. Replica reads are always served from the
9861    /// replica. This is the default and preserves existing behavior exactly.
9862    #[default]
9863    Off,
9864    /// Once the current request checks out a **primary** connection (via `Db`
9865    /// or a generated mutating repository method), all subsequent
9866    /// replica-eligible reads within the same request are redirected to the
9867    /// primary. Analogous to Laravel's "sticky" behavior.
9868    Request,
9869    /// Like `request`, and additionally pins a client's reads to the primary
9870    /// for [`pin_after_write_secs`](DatabaseConfig::pin_after_write_secs)
9871    /// seconds after a write, via a signed `autumn.ryw` cookie. Reads within
9872    /// that window are served from the primary even if the request itself
9873    /// performed no write. Analogous to Rails' automatic role switching.
9874    Session,
9875}
9876
9877impl std::str::FromStr for ReadYourWrites {
9878    type Err = ();
9879
9880    fn from_str(value: &str) -> Result<Self, Self::Err> {
9881        match value.trim().to_ascii_lowercase().as_str() {
9882            "off" => Ok(Self::Off),
9883            "request" => Ok(Self::Request),
9884            "session" => Ok(Self::Session),
9885            _ => Err(()),
9886        }
9887    }
9888}
9889
9890/// A logical slot assignment entry in a shard's `slots` list.
9891///
9892/// Accepts a single slot index (`5`) or an inclusive range written as a
9893/// string (`"0-31"`). A string holding a single number (`"5"`) is also
9894/// accepted so environment-variable overrides can pass everything as text.
9895#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9896#[serde(untagged)]
9897pub enum SlotSpec {
9898    /// A single slot index.
9899    Index(u16),
9900    /// `"A-B"` inclusive range, or `"N"` single index.
9901    Range(String),
9902}
9903
9904impl SlotSpec {
9905    /// Expand into concrete slot indices.
9906    ///
9907    /// # Errors
9908    ///
9909    /// Returns a human-readable message when a range string is malformed
9910    /// or inverted (`"31-0"`).
9911    pub fn expand(&self) -> Result<Vec<u16>, String> {
9912        match self {
9913            Self::Index(slot) => Ok(vec![*slot]),
9914            Self::Range(spec) => {
9915                let spec = spec.trim();
9916                let parse = |s: &str| {
9917                    s.trim()
9918                        .parse::<u16>()
9919                        .map_err(|_| format!("invalid slot {s:?} in {spec:?}"))
9920                };
9921                match spec.split_once('-') {
9922                    None => Ok(vec![parse(spec)?]),
9923                    Some((start, end)) => {
9924                        let (start, end) = (parse(start)?, parse(end)?);
9925                        if start > end {
9926                            return Err(format!("inverted slot range {spec:?}"));
9927                        }
9928                        Ok((start..=end).collect())
9929                    }
9930                }
9931            }
9932        }
9933    }
9934}
9935
9936/// One horizontal shard of the application's data, declared via
9937/// `[[database.shards]]` in `autumn.toml`.
9938///
9939/// Each shard is a full primary/replica topology of its own, so the
9940/// replica story composes with sharding: any shard may have a read
9941/// replica, role-specific pool sizes, and its own fallback behavior.
9942/// Fields left unset fall back to the corresponding `[database]` value.
9943///
9944/// # Routing: keys → logical slots → shards
9945///
9946/// Routing keys hash onto a fixed set of [`SLOT_COUNT`] (16384) **logical
9947/// slots**, and each slot maps to one shard. The key→slot hash is a
9948/// permanent contract; the slot→shard map is plain configuration. Growing
9949/// from two shards to three means moving whole slots — copy a slot's rows
9950/// to the new shard, flip its `slots` entry, deploy — without rehashing
9951/// any keys.
9952///
9953/// When **every** shard declares [`slots`](Self::slots), declaration
9954/// order is meaningless and entries can be reordered, renamed, or
9955/// removed freely (as long as the map still covers every slot exactly
9956/// once). When **no** shard declares `slots`, the framework auto-splits
9957/// the slot space into contiguous even ranges **by declaration order**
9958/// — convenient to start with, but reordering entries then moves data.
9959/// Pin explicit `slots` before making any topology change.
9960///
9961/// # Example
9962///
9963/// ```toml
9964/// [database]
9965/// primary_url = "postgres://db-control/app"   # control role: jobs, sessions, flags
9966///
9967/// [[database.shards]]
9968/// name = "shard0"
9969/// primary_url = "postgres://db-shard0/app"
9970/// slots = ["0-8191"]
9971///
9972/// [[database.shards]]
9973/// name = "shard1"
9974/// primary_url = "postgres://db-shard1/app"
9975/// slots = ["8192-16383"]
9976/// replica_url = "postgres://db-shard1-ro/app"
9977/// replica_fallback = "primary"
9978/// ```
9979#[derive(Debug, Clone, Default, Deserialize)]
9980pub struct ShardConfig {
9981    /// Stable shard identity used in logs, metric tags, health component
9982    /// names (`db:shard:<name>`), and `autumn migrate --shard <name>`.
9983    ///
9984    /// Must be non-empty, unique across shards, and restricted to
9985    /// `[a-z0-9_-]` so it can be embedded in metric/health keys.
9986    pub name: String,
9987
9988    /// Postgres URL for this shard's primary/write role. Required.
9989    pub primary_url: String,
9990
9991    /// Logical slots this shard owns, as indices and/or `"A-B"` inclusive
9992    /// ranges (e.g. `slots = ["0-8191", 16000, "16382-16383"]`).
9993    ///
9994    /// All-or-none across shards: either every shard declares `slots`
9995    /// (explicit map covering `0..16384` exactly once; an empty list
9996    /// marks a drained shard being decommissioned) or none does
9997    /// (contiguous auto-split by declaration order).
9998    #[serde(default)]
9999    pub slots: Option<Vec<SlotSpec>>,
10000
10001    /// Optional Postgres URL for this shard's read-replica role.
10002    #[serde(default)]
10003    pub replica_url: Option<String>,
10004
10005    /// Optional primary pool size override. Falls back to
10006    /// `database.primary_pool_size`, then `database.pool_size`.
10007    #[serde(default)]
10008    pub primary_pool_size: Option<usize>,
10009
10010    /// Optional replica pool size override. Falls back to
10011    /// `database.replica_pool_size`, then `database.pool_size`.
10012    #[serde(default)]
10013    pub replica_pool_size: Option<usize>,
10014
10015    /// Optional replica fallback override. Falls back to
10016    /// `database.replica_fallback`.
10017    #[serde(default)]
10018    pub replica_fallback: Option<ReplicaFallback>,
10019}
10020
10021impl ShardConfig {
10022    /// Resolved primary pool size for this shard.
10023    #[must_use]
10024    pub fn effective_primary_pool_size(&self, defaults: &DatabaseConfig) -> usize {
10025        self.primary_pool_size
10026            .unwrap_or_else(|| defaults.effective_primary_pool_size())
10027    }
10028
10029    /// Resolved replica pool size for this shard.
10030    #[must_use]
10031    pub fn effective_replica_pool_size(&self, defaults: &DatabaseConfig) -> usize {
10032        self.replica_pool_size
10033            .unwrap_or_else(|| defaults.effective_replica_pool_size())
10034    }
10035
10036    /// Resolved replica fallback behavior for this shard.
10037    #[must_use]
10038    pub fn effective_replica_fallback(&self, defaults: &DatabaseConfig) -> ReplicaFallback {
10039        self.replica_fallback.unwrap_or(defaults.replica_fallback)
10040    }
10041}
10042
10043/// Which database engine a configured connection target names.
10044///
10045/// Autumn recognizes two backends. Postgres is the default runtime; `SQLite`
10046/// (issue #1614) is served by the runtime built with the crate's `sqlite`
10047/// cargo feature. Detection here is backend-neutral and always compiled, so a
10048/// `SQLite` target validates on either build; a build that cannot serve the
10049/// detected backend refuses at pool construction with a message naming the
10050/// feature (see [`create_pool`](crate::db::create_pool)).
10051///
10052/// # Detection rules
10053///
10054/// [`DatabaseBackend::detect`] classifies a target string by its scheme:
10055///
10056/// - `postgres://` / `postgresql://` URLs, and libpq keyword/value strings
10057///   (`host=db user=app sslmode=require`), are [`Postgres`](Self::Postgres) —
10058///   exactly the shapes the connection pool already accepts.
10059/// - `sqlite://<path>`, `sqlite:<path>`, and `file:<path>` targets are
10060///   [`Sqlite`](Self::Sqlite). `sqlite://` is the canonical, unambiguous form
10061///   and should be preferred.
10062/// - Anything else (including a **bare filesystem path** like
10063///   `/var/lib/app.db`) is deliberately *not* recognized and returns `None`.
10064///   A bare path is ambiguous — it carries no scheme distinguishing it from a
10065///   typo'd URL — so callers must spell `SQLite` targets with an explicit
10066///   `sqlite://` (or `sqlite:` / `file:`) scheme.
10067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10068pub enum DatabaseBackend {
10069    /// `PostgreSQL` — the default runtime backend.
10070    Postgres,
10071    /// `SQLite` — served by a build compiled with the `sqlite` feature
10072    /// (issue #1614); refused at pool construction by any other build.
10073    Sqlite,
10074}
10075
10076impl DatabaseBackend {
10077    /// Detect the backend named by a database target string, or `None` when the
10078    /// target matches no recognized shape. See the [type docs](Self) for the
10079    /// full rule table, including why a bare filesystem path is not recognized.
10080    #[must_use]
10081    pub fn detect(target: &str) -> Option<Self> {
10082        // Check the SQLite schemes first: they are unambiguous prefixes and
10083        // never overlap with a Postgres URL or keyword/value string.
10084        if is_sqlite_target(target) {
10085            Some(Self::Sqlite)
10086        } else if is_pg_connection_string(target) {
10087            Some(Self::Postgres)
10088        } else {
10089            None
10090        }
10091    }
10092
10093    /// Lowercase name used in boot-time error messages.
10094    const fn as_str(self) -> &'static str {
10095        match self {
10096            Self::Postgres => "postgres",
10097            Self::Sqlite => "sqlite",
10098        }
10099    }
10100}
10101
10102impl std::fmt::Display for DatabaseBackend {
10103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10104        f.write_str(self.as_str())
10105    }
10106}
10107
10108/// Whether `s` names a `SQLite` target: the canonical `sqlite://<path>` URL, the
10109/// shorter `sqlite:<path>` form, or a `file:<path>` target. A bare filesystem
10110/// path is intentionally excluded (see [`DatabaseBackend`]).
10111fn is_sqlite_target(s: &str) -> bool {
10112    // `sqlite://` is subsumed by the `sqlite:` prefix; both are accepted.
10113    s.starts_with("sqlite:") || s.starts_with("file:")
10114}
10115
10116/// Database connection configuration.
10117///
10118/// When `url` is `None` (the default), the application runs without a
10119/// database -- useful for static-site or API-gateway use cases. Set a
10120/// Postgres URL to enable the connection pool and the [`Db`](crate::Db)
10121/// extractor.
10122///
10123/// # Defaults
10124///
10125/// | Field | Default |
10126/// |-------|---------|
10127/// | `url` | `None` |
10128/// | `primary_url` | `None` |
10129/// | `replica_url` | `None` |
10130/// | `pool_size` | `10` |
10131/// | `primary_pool_size` | `None` |
10132/// | `replica_pool_size` | `None` |
10133/// | `replica_fallback` | `fail_readiness` |
10134/// | `connect_timeout_secs` | `5` |
10135/// | `auto_migrate` | `None` |
10136/// | `auto_migrate_in_production` | `false` |
10137/// | `shards` | `[]` |
10138///
10139/// # Examples
10140///
10141/// ```rust
10142/// use autumn_web::config::DatabaseConfig;
10143///
10144/// let db = DatabaseConfig::default();
10145/// assert!(db.url.is_none());
10146/// assert_eq!(db.pool_size, 10);
10147/// ```
10148#[derive(Debug, Clone, Deserialize)]
10149pub struct DatabaseConfig {
10150    /// Postgres connection URL. `None` means no database is configured.
10151    ///
10152    /// Compatibility alias for the primary/write role. New multi-role
10153    /// deployments should prefer [`primary_url`](Self::primary_url).
10154    ///
10155    /// When present, must start with `postgres://` or `postgresql://`, or be
10156    /// a libpq-style keyword/value connection string
10157    /// (`host=db user=app dbname=app sslmode=require`).
10158    #[serde(default)]
10159    pub url: Option<String>,
10160
10161    /// Postgres URL for the primary/write role.
10162    ///
10163    /// All writes, transactions, advisory locks, and migrations use this role.
10164    /// When unset, [`url`](Self::url) remains the single-primary fallback.
10165    #[serde(default)]
10166    pub primary_url: Option<String>,
10167
10168    /// Optional Postgres URL for the read/replica role.
10169    ///
10170    /// Read-only paths may use this pool when configured. If omitted, read
10171    /// paths use the primary role.
10172    #[serde(default)]
10173    pub replica_url: Option<String>,
10174
10175    /// Maximum number of connections in the pool. Default: `10`.
10176    ///
10177    /// Compatibility/default pool size used for both roles unless a
10178    /// role-specific size is set.
10179    #[serde(default = "default_pool_size")]
10180    pub pool_size: usize,
10181
10182    /// Optional primary/write role pool size.
10183    #[serde(default)]
10184    pub primary_pool_size: Option<usize>,
10185
10186    /// Optional read/replica role pool size.
10187    #[serde(default)]
10188    pub replica_pool_size: Option<usize>,
10189
10190    /// Deterministic behavior for configured replicas that cannot safely serve
10191    /// reads. Default: fail readiness.
10192    #[serde(default)]
10193    pub replica_fallback: ReplicaFallback,
10194
10195    /// Post-write read pinning strategy. Default: `off` (no pinning).
10196    ///
10197    /// Set to `request` to pin reads to the primary for the remainder of the
10198    /// request after the first write. Set to `session` to additionally pin
10199    /// reads across requests via a signed cookie.
10200    ///
10201    /// Override via `AUTUMN_DATABASE__READ_YOUR_WRITES`.
10202    #[serde(default)]
10203    pub read_your_writes: ReadYourWrites,
10204
10205    /// Duration (seconds) for cross-request session pins.
10206    ///
10207    /// Only used when `read_your_writes = "session"`. A signed `autumn.ryw`
10208    /// cookie pins the client's reads to the primary for this many seconds
10209    /// after a write. Default: `5`.
10210    ///
10211    /// Override via `AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS`.
10212    #[serde(default = "default_pin_after_write_secs")]
10213    pub pin_after_write_secs: u64,
10214
10215    /// Maximum replica lag, in milliseconds (issue #3065).
10216    ///
10217    /// When set, the app measures the replica lag in the background, and
10218    /// `/ready` reports the last sample. While the lag is over the limit, or
10219    /// not known, reads go to
10220    /// the primary. Lag alone does not fail readiness. `None` or `0` turns
10221    /// the check off. Override via `AUTUMN_DATABASE__REPLICA_MAX_LAG_MS`.
10222    #[serde(default)]
10223    pub replica_max_lag_ms: Option<u64>,
10224
10225    /// Log a warning at boot when a database URL looks like a connection
10226    /// pooler (`PgBouncer`, RDS Proxy, Supavisor, Neon). Default: `true`.
10227    /// See `docs/guide/connection-poolers.md`. Override via
10228    /// `AUTUMN_DATABASE__WARN_ON_POOLER`.
10229    #[serde(default = "default_warn_on_pooler")]
10230    pub warn_on_pooler: bool,
10231
10232    /// Seconds to wait while acquiring a pooled connection, including
10233    /// creating a new connection when the pool grows.
10234    /// Default: `5`.
10235    #[serde(default = "default_connect_timeout")]
10236    pub connect_timeout_secs: u64,
10237
10238    /// Bounded startup wait (seconds) for the database to become reachable
10239    /// before the migrator fails. `0` (the default) disables the wait and
10240    /// preserves the current fail-fast behaviour — a single connection attempt,
10241    /// no retry.  Set a non-zero value (e.g. `60`) to have `autumn migrate`
10242    /// retry with capped exponential backoff until either the database accepts
10243    /// connections or the window elapses.
10244    ///
10245    /// Override via `AUTUMN_DATABASE__STARTUP_WAIT_SECS`.
10246    #[serde(default)]
10247    pub startup_wait_secs: u64,
10248
10249    /// Profile-agnostic explicit override for startup migration auto-apply
10250    /// (issue #1903). `None` (the default, when the key is absent) leaves the
10251    /// decision to convention: `dev`/`development` auto-apply, every other
10252    /// profile (`prod`/`production` **and** custom names like `fly`/`staging`)
10253    /// is opt-in. `Some(true)` / `Some(false)` overrides that convention on
10254    /// **any** profile.
10255    ///
10256    /// This supersedes [`Self::auto_migrate_in_production`], which is retained
10257    /// as a back-compat alias: when `auto_migrate` is unset but
10258    /// `auto_migrate_in_production = true`, auto-apply is enabled on any
10259    /// non-`dev` profile (so a custom-profile operator's existing config finally
10260    /// takes effect). `auto_migrate` wins when both are set.
10261    ///
10262    /// Override via `AUTUMN_DATABASE__AUTO_MIGRATE`.
10263    #[serde(default)]
10264    pub auto_migrate: Option<bool>,
10265
10266    /// Back-compat alias for [`Self::auto_migrate`] (issue #1903). When `true`,
10267    /// permits automatic migration application on any non-`dev` profile (not
10268    /// just `prod`/`production` — the old name-gated behavior silently skipped
10269    /// custom profiles). Default: `false`. Prefer setting `auto_migrate`
10270    /// directly; this key is honored only when `auto_migrate` is unset.
10271    ///
10272    /// Keep this disabled for multi-replica production fleets and use an
10273    /// explicit migration job (`autumn migrate`) instead.
10274    #[serde(default)]
10275    pub auto_migrate_in_production: bool,
10276
10277    /// Optional database statement timeout. `"0s"` disables it.
10278    ///
10279    /// Set as a session `SET` on checkout, and again as `SET LOCAL` at the
10280    /// start of each framework transaction, so a transaction pooler such as
10281    /// `PgBouncer` cannot drop it. The `prod` profile sets `30s` (#3057).
10282    ///
10283    /// Override via `AUTUMN_DATABASE__STATEMENT_TIMEOUT`.
10284    #[serde(deserialize_with = "deserialize_option_duration", default)]
10285    pub statement_timeout: Option<std::time::Duration>,
10286
10287    /// Optional `idle_in_transaction_session_timeout`. `"0s"` disables it.
10288    ///
10289    /// Set as `SET LOCAL` at the start of each framework transaction. Postgres
10290    /// ends a session that stays idle in an open transaction for longer than
10291    /// this. The `prod` profile sets `60s` (#3057).
10292    ///
10293    /// Override via `AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT`.
10294    #[serde(deserialize_with = "deserialize_option_duration", default)]
10295    pub idle_in_transaction_timeout: Option<std::time::Duration>,
10296
10297    /// Postgres `lock_timeout` for the migration session. Default: `5s`.
10298    /// `"0s"` disables it.
10299    ///
10300    /// A migration that waits longer than this for a table lock fails, and
10301    /// the migrator retries it (see [`Self::migration_lock_retries`]). This
10302    /// stops a DDL statement that queues behind a long transaction from
10303    /// blocking all traffic on that table (#3057).
10304    ///
10305    /// Override via `AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT`.
10306    #[serde(
10307        deserialize_with = "deserialize_duration",
10308        default = "default_migration_lock_timeout"
10309    )]
10310    pub migration_lock_timeout: std::time::Duration,
10311
10312    /// Retries after a migration fails on `migration_lock_timeout`.
10313    /// Default: `5`. `0` disables the retry. Each retry waits a jittered,
10314    /// exponential delay.
10315    ///
10316    /// Override via `AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES`.
10317    #[serde(default = "default_migration_lock_retries")]
10318    pub migration_lock_retries: u32,
10319
10320    /// Slow query threshold. Default: `500ms`.
10321    #[serde(
10322        deserialize_with = "deserialize_duration",
10323        default = "default_slow_query_threshold"
10324    )]
10325    pub slow_query_threshold: std::time::Duration,
10326
10327    /// Horizontal shards, declared as `[[database.shards]]` entries.
10328    ///
10329    /// Empty (the default) means the application is unsharded and only the
10330    /// `url`/`primary_url`/`replica_url` roles above apply. When non-empty,
10331    /// those top-level roles become the **control** topology — framework
10332    /// state (jobs, scheduler locks, sessions, feature flags) lives there
10333    /// while tenant data is routed across the shards. See [`ShardConfig`].
10334    #[serde(default)]
10335    pub shards: Vec<ShardConfig>,
10336
10337    /// Route tenants through the control-plane `_autumn_shard_directory` table
10338    /// (a [`DirectoryShardRouter`](crate::sharding::DirectoryShardRouter))
10339    /// instead of pure slot-hash routing. Default: `false`.
10340    ///
10341    /// Tenants with a directory row are pinned to the named shard; everyone
10342    /// else falls back to the hash router. Usually set via
10343    /// [`AppBuilder::with_directory_shard_router`](crate::app::AppBuilder::with_directory_shard_router).
10344    /// Ignored when no shards are configured or an explicit
10345    /// [`with_shard_router`](crate::app::AppBuilder::with_shard_router) is set.
10346    #[serde(default)]
10347    pub directory_shard_router: bool,
10348
10349    /// Emit a startup warning when the aggregate maximum connection count
10350    /// across the control topology and every shard pool reaches this value.
10351    /// Default: `100`.
10352    ///
10353    /// Pool sizes multiply across shards: an N-shard fleet with a pool size
10354    /// of 20 opens up to `20 * N` connections, which can exhaust Postgres's
10355    /// `max_connections` (default 100) long before the app looks busy. This
10356    /// threshold surfaces that footgun at boot. Set to `0` to disable.
10357    #[serde(default = "default_max_connections_warn_threshold")]
10358    pub max_connections_warn_threshold: usize,
10359
10360    /// A fleet of `SQLite` databases: one file per tenant or one per routing
10361    /// slot (ADR 0019). `None` (the default) means no fleet.
10362    ///
10363    /// With a fleet, the top-level `url` is the **control** database (sessions,
10364    /// jobs, flags) and tenant data is routed by [`ShardedDb`](crate::sharding::ShardedDb),
10365    /// [`Shards`](crate::sharding::Shards) and `#[repository(sharded)]` to a
10366    /// database file opened on first use. Requires the `sqlite` feature and a
10367    /// `sqlite:` control database; mutually exclusive with
10368    /// [`shards`](Self::shards). See [`DatabaseFleetConfig`].
10369    #[serde(default)]
10370    pub fleet: Option<DatabaseFleetConfig>,
10371}
10372
10373/// `[database.fleet]`: one `SQLite` database per tenant or per routing slot.
10374///
10375/// ```toml
10376/// [database]
10377/// url = "sqlite:///var/lib/app/control.db"
10378///
10379/// [database.fleet]
10380/// mode = "tenant"                  # or "slot"
10381/// root = "/var/lib/app/fleet"
10382/// # path = "{bucket}/{tenant}.db"  # the default for mode = "tenant"
10383/// max_open = 256
10384/// ```
10385///
10386/// See [`crate::fleet_layout`] for the path template rules and ADR 0019 for
10387/// the design.
10388#[derive(Debug, Clone, Deserialize)]
10389pub struct DatabaseFleetConfig {
10390    /// What one database holds: a tenant, or a routing slot.
10391    pub mode: crate::fleet_layout::FleetMode,
10392
10393    /// Directory that holds the fleet. Created at boot when missing. Must be
10394    /// on local storage (`SQLite` file locking is not safe on network file
10395    /// systems).
10396    pub root: String,
10397
10398    /// Path template under [`root`](Self::root). Default:
10399    /// [`FleetMode::default_path`](crate::fleet_layout::FleetMode::default_path).
10400    #[serde(default)]
10401    pub path: Option<String>,
10402
10403    /// Most databases kept open at once. The least recently used idle one is
10404    /// closed past this. Default: `256`. Each open database holds up to
10405    /// [`pool_size`](Self::pool_size) connections and three file descriptors
10406    /// per connection (database, `-wal`, `-shm`).
10407    #[serde(default = "default_fleet_max_open")]
10408    pub max_open: usize,
10409
10410    /// Connections per open database. Default: `2`. `SQLite` has one writer
10411    /// per database, so a large pool only adds lock contention.
10412    #[serde(default = "default_fleet_pool_size")]
10413    pub pool_size: usize,
10414
10415    /// Create a database that does not exist yet on first use. Default:
10416    /// `true` for `mode = "slot"`, `false` for `mode = "tenant"` — tenant ids
10417    /// come from requests, so a tenant database is provisioned explicitly
10418    /// (`DatabaseFleet::provision`, in `autumn_web::db::fleet`)
10419    /// unless the app opts in here.
10420    #[serde(default)]
10421    pub create_on_demand: Option<bool>,
10422
10423    /// Close a database that has not been used for this long. Default: `300`.
10424    /// `0` closes databases only when [`max_open`](Self::max_open) is reached.
10425    #[serde(default = "default_fleet_idle_close_secs")]
10426    pub idle_close_secs: u64,
10427
10428    /// With `[replication]` on, rebuild a database whose file is missing from
10429    /// its replica before opening it. Default: `false`.
10430    ///
10431    /// This is how a host with a fresh volume takes over a slot range or a
10432    /// tenant: the first request restores the database from object storage.
10433    /// Turn on [`ownership`](Self::ownership) too, so the old owner is fenced
10434    /// first (ADR 0019 §7).
10435    #[serde(default)]
10436    pub restore_missing: bool,
10437
10438    /// Slot ownership with epochs and fencing (issue #3233). `None`: this
10439    /// host serves every slot and nothing fences it.
10440    #[serde(default)]
10441    pub ownership: Option<FleetOwnershipConfig>,
10442}
10443
10444/// `[database.fleet.ownership]`: the slot ranges this host claims on the
10445/// ownership table of the control database (ADR 0019 §7).
10446///
10447/// ```toml
10448/// [database.fleet.ownership]
10449/// owner = "host-a"       # unique per volume
10450/// slots = ["0-8191"]     # default: every slot
10451/// lease_secs = 30
10452/// ```
10453#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
10454pub struct FleetOwnershipConfig {
10455    /// This host's id in the ownership table. The processes on one volume
10456    /// (one fleet root) use the same id and the same `slots`. Two volumes
10457    /// must never share one.
10458    pub owner: String,
10459
10460    /// The slot ranges to claim, each `"A-B"` or `N`. Each entry is one claim.
10461    /// Default: every slot, as one range.
10462    #[serde(default)]
10463    pub slots: Vec<SlotSpec>,
10464
10465    /// The lease, in seconds. The host renews every third of it. Default:
10466    /// `30`. From `3` to `3600`. Clock skew plus the longest request must be
10467    /// less than a third of it.
10468    #[serde(default = "default_fleet_lease_secs")]
10469    pub lease_secs: u64,
10470}
10471
10472impl FleetOwnershipConfig {
10473    /// The ranges as inclusive `(start, end)` pairs, in order.
10474    ///
10475    /// # Errors
10476    ///
10477    /// A message for a malformed, inverted, out-of-range or overlapping range.
10478    pub fn ranges(&self) -> Result<Vec<(u16, u16)>, String> {
10479        if self.slots.is_empty() {
10480            return Ok(vec![(0, SLOT_COUNT - 1)]);
10481        }
10482        let mut ranges = Vec::with_capacity(self.slots.len());
10483        for spec in &self.slots {
10484            let range = match spec {
10485                SlotSpec::Index(slot) => (*slot, *slot),
10486                SlotSpec::Range(text) => {
10487                    let slots = spec.expand()?;
10488                    match (slots.first(), slots.last()) {
10489                        (Some(start), Some(end)) => (*start, *end),
10490                        _ => return Err(format!("empty slot range {text:?}")),
10491                    }
10492                }
10493            };
10494            if range.1 >= SLOT_COUNT {
10495                return Err(format!(
10496                    "slot {} is out of range (0-{})",
10497                    range.1,
10498                    SLOT_COUNT - 1
10499                ));
10500            }
10501            ranges.push(range);
10502        }
10503        ranges.sort_unstable();
10504        if let Some(pair) = ranges.windows(2).find(|pair| pair[0].1 >= pair[1].0) {
10505            return Err(format!(
10506                "slot ranges {}-{} and {}-{} overlap",
10507                pair[0].0, pair[0].1, pair[1].0, pair[1].1
10508            ));
10509        }
10510        Ok(ranges)
10511    }
10512
10513    /// Check the section.
10514    ///
10515    /// # Errors
10516    ///
10517    /// A [`ConfigError::Validation`] naming the field.
10518    pub fn validate(&self) -> Result<(), ConfigError> {
10519        let owner = &self.owner;
10520        if owner.is_empty()
10521            || owner.len() > 128
10522            || owner.chars().any(char::is_control)
10523            || owner.trim() != owner
10524        {
10525            return Err(ConfigError::Validation(
10526                "database.fleet.ownership.owner must be 1 to 128 bytes, with no control \
10527                 characters and no white space at the start or end"
10528                    .to_owned(),
10529            ));
10530        }
10531        self.ranges()
10532            .map_err(|e| ConfigError::Validation(format!("database.fleet.ownership.slots: {e}")))?;
10533        if !(3..=3600).contains(&self.lease_secs) {
10534            return Err(ConfigError::Validation(
10535                "database.fleet.ownership.lease_secs must be 3 to 3600".to_owned(),
10536            ));
10537        }
10538        Ok(())
10539    }
10540}
10541
10542const fn default_fleet_lease_secs() -> u64 {
10543    30
10544}
10545
10546impl DatabaseFleetConfig {
10547    /// The path template, defaulted by mode.
10548    #[must_use]
10549    pub fn path_template(&self) -> &str {
10550        self.path
10551            .as_deref()
10552            .unwrap_or_else(|| self.mode.default_path())
10553    }
10554
10555    /// Whether a missing database is created on first use.
10556    #[must_use]
10557    pub fn effective_create_on_demand(&self) -> bool {
10558        self.create_on_demand
10559            .unwrap_or_else(|| self.mode.default_create_on_demand())
10560    }
10561
10562    /// Check the fleet section on its own (the cross-section rules live in
10563    /// [`DatabaseConfig::validate`]).
10564    ///
10565    /// # Errors
10566    ///
10567    /// A [`ConfigError::Validation`] naming the field.
10568    pub fn validate(&self) -> Result<(), ConfigError> {
10569        if self.root.trim().is_empty() {
10570            return Err(ConfigError::Validation(
10571                "database.fleet.root must name a directory".to_owned(),
10572            ));
10573        }
10574        if self.root.starts_with("sqlite:") || self.root.starts_with("file:") {
10575            return Err(ConfigError::Validation(format!(
10576                "database.fleet.root {:?} is a directory, not a database URL",
10577                self.root
10578            )));
10579        }
10580        crate::fleet_layout::FleetPathTemplate::parse(self.path_template(), self.mode)
10581            .map_err(|e| ConfigError::Validation(e.to_string()))?;
10582        if self.max_open == 0 {
10583            return Err(ConfigError::Validation(
10584                "database.fleet.max_open must be at least 1".to_owned(),
10585            ));
10586        }
10587        if self.pool_size == 0 {
10588            return Err(ConfigError::Validation(
10589                "database.fleet.pool_size must be at least 1".to_owned(),
10590            ));
10591        }
10592        if let Some(ownership) = &self.ownership {
10593            ownership.validate()?;
10594        }
10595        Ok(())
10596    }
10597}
10598
10599const fn default_fleet_max_open() -> usize {
10600    256
10601}
10602
10603const fn default_fleet_pool_size() -> usize {
10604    2
10605}
10606
10607const fn default_fleet_idle_close_secs() -> u64 {
10608    300
10609}
10610
10611/// Decide whether the aggregate connection count warrants a startup warning.
10612///
10613/// Pure so the boundary condition is unit-testable without booting an app.
10614/// A `threshold` of `0` disables the warning entirely.
10615pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
10616    threshold != 0 && total >= threshold
10617}
10618
10619/// Render a sorted slot list as compact `A-B` ranges for error messages
10620/// (a gap in a 16384-slot map would otherwise print thousands of indices).
10621fn format_slot_ranges(slots: &[usize]) -> String {
10622    fn render(start: usize, end: usize) -> String {
10623        if start == end {
10624            start.to_string()
10625        } else {
10626            format!("{start}-{end}")
10627        }
10628    }
10629    let mut ranges: Vec<String> = Vec::new();
10630    let mut iter = slots.iter().copied();
10631    let Some(mut start) = iter.next() else {
10632        return String::new();
10633    };
10634    let mut end = start;
10635    for slot in iter {
10636        if slot != end + 1 {
10637            ranges.push(render(start, end));
10638            start = slot;
10639        }
10640        end = slot;
10641    }
10642    ranges.push(render(start, end));
10643    ranges.join(", ")
10644}
10645
10646/// Number of logical routing slots shared across all shards. Fixed,
10647/// not configurable — the same constant for every Autumn deployment,
10648/// matching Redis Cluster and Valkey.
10649///
10650/// Keys hash onto `0..SLOT_COUNT` and each slot maps to one shard, so
10651/// resharding means moving whole slots between shards rather than
10652/// rehashing keys. Slots are pure routing-table entries (no pools, no
10653/// per-slot resources), so the fixed count costs almost nothing while
10654/// removing the classic "chose too few partitions on day one"
10655/// failure mode: there is no value to pick and nothing to outgrow
10656/// short of 16384 physical shards.
10657pub const SLOT_COUNT: u16 = 16384;
10658
10659/// The resolved slot assignment for a single shard, expressed as a name and a
10660/// compact range string (e.g. `"0-8191"` or `"0-5460, 10923-16383"`).
10661///
10662/// Used by the boot-time shard-map guard to compare the freshly-computed
10663/// auto-split against the map stored on first boot. An empty `ranges` string
10664/// represents a drained shard (all slots moved away); that only arises in
10665/// explicit-slot mode, where the guard is inert.
10666#[derive(Debug, Clone, PartialEq, Eq)]
10667pub struct ShardSlotAssignment {
10668    pub name: String,
10669    pub ranges: String,
10670}
10671
10672/// Guard: compare the freshly-computed slot map against the stored map.
10673///
10674/// Returns `Ok(())` — no action required — when:
10675/// - `auto_split` is `false` (explicit-slot mode: operator-managed, no guard),
10676/// - `stored` is `None` (first boot: nothing to compare against), or
10677/// - the computed and stored maps are identical (order-insensitive).
10678///
10679/// Returns `Err` with a human-readable message when auto-split is active, a
10680/// stored map exists, and the maps differ.
10681///
10682/// Pure and sync so it can be unit-tested without a database.
10683///
10684/// # Errors
10685///
10686/// Returns a `String` description when the auto-split map differs from the
10687/// stored map.
10688pub fn check_stored_slot_map(
10689    auto_split: bool,
10690    computed: &[ShardSlotAssignment],
10691    stored: Option<&[ShardSlotAssignment]>,
10692) -> Result<(), String> {
10693    fn to_map(assignments: &[ShardSlotAssignment]) -> std::collections::BTreeMap<&str, &str> {
10694        assignments
10695            .iter()
10696            .map(|a| (a.name.as_str(), a.ranges.as_str()))
10697            .collect()
10698    }
10699    if !auto_split {
10700        return Ok(());
10701    }
10702    let Some(stored) = stored else {
10703        return Ok(());
10704    };
10705    if to_map(computed) == to_map(stored) {
10706        return Ok(());
10707    }
10708    let computed_names: Vec<&str> = computed.iter().map(|a| a.name.as_str()).collect();
10709    let stored_names: Vec<&str> = stored.iter().map(|a| a.name.as_str()).collect();
10710    Err(format!(
10711        "shard slot map mismatch — auto-split with {} shards ({}) produces a different \
10712         map than the stored map ({} shards: {}). Set explicit [[database.shards]] slot \
10713         ranges matching the stored map, then move data between shards deliberately \
10714         before changing the topology.",
10715        computed.len(),
10716        computed_names.join(", "),
10717        stored.len(),
10718        stored_names.join(", "),
10719    ))
10720}
10721
10722/// The cross-backend consistency rule, as a single source of truth.
10723///
10724/// Shared by boot-time validation ([`DatabaseConfig::validate`], via
10725/// `DatabaseConfig::validate_backend_consistency`) and out-of-process callers
10726/// such as `autumn doctor`, so both agree for *every* role/backend mismatch
10727/// without re-deriving the rule.
10728///
10729/// The roles map to the config fields: `url` is the legacy `database.url`,
10730/// `primary_url` is `database.primary_url`, `replica_url` is
10731/// `database.replica_url`, and `has_shards` is whether any `[[database.shards]]`
10732/// are configured. The effective primary backend is `primary_url` if set, else
10733/// the legacy `url` (mirroring [`DatabaseConfig::effective_primary_url`]).
10734///
10735/// `SQLite` is a valid *target* but a narrower runtime than Postgres, so several
10736/// Postgres-only topologies (read replicas, horizontal shards, mixed backends)
10737/// are refused up front with actionable messages. The Postgres path is
10738/// behaviourally unchanged: a Postgres primary with Postgres roles and no
10739/// `SQLite` anywhere hits none of these branches and returns `Ok(())`.
10740///
10741/// This is the single source of truth for the rule; do not re-implement it.
10742///
10743/// # Errors
10744///
10745/// Returns `Err(message)` describing the first offending role when the topology
10746/// mixes backends or pairs a `SQLite` primary with a Postgres-only feature. The
10747/// message is byte-identical to what boot-time validation reports.
10748pub fn database_backend_consistency(
10749    url: Option<&str>,
10750    primary_url: Option<&str>,
10751    replica_url: Option<&str>,
10752    has_shards: bool,
10753) -> Result<(), String> {
10754    let Some(primary_backend) = primary_url.or(url).and_then(DatabaseBackend::detect) else {
10755        return Ok(());
10756    };
10757
10758    if primary_backend == DatabaseBackend::Sqlite {
10759        // Read replicas are a Postgres topology concept; SQLite has no
10760        // replica role to serve reads from.
10761        if replica_url.is_some() {
10762            return Err(
10763                "database.replica_url is set but the primary target is SQLite; \
10764                 read replicas require the postgres backend"
10765                    .to_owned(),
10766            );
10767        }
10768        // Horizontal sharding is Postgres-only.
10769        if has_shards {
10770            return Err(
10771                "database.shards are configured but the primary target is SQLite; \
10772                 database shards require the postgres backend"
10773                    .to_owned(),
10774            );
10775        }
10776    }
10777
10778    // Every configured connection role must name the same backend. Mixing
10779    // (e.g. a Postgres primary with a SQLite replica, or vice versa) cannot
10780    // work and is a boot-time misconfiguration rather than a first-query
10781    // surprise.
10782    for (field, url) in [("database.url", url), ("database.replica_url", replica_url)] {
10783        if let Some(url) = url
10784            && DatabaseBackend::detect(url) != Some(primary_backend)
10785        {
10786            return Err(format!(
10787                "{field} does not match the primary database backend \
10788                 ({primary_backend}); every configured database role must use \
10789                 the same backend"
10790            ));
10791        }
10792    }
10793
10794    Ok(())
10795}
10796
10797impl DatabaseConfig {
10798    /// Resolved primary/write database URL.
10799    #[must_use]
10800    pub fn effective_primary_url(&self) -> Option<&str> {
10801        self.primary_url.as_deref().or(self.url.as_deref())
10802    }
10803
10804    /// Resolved primary/write database URL, but only when it names Postgres.
10805    ///
10806    /// Autumn ships subsystems that are Postgres-only by construction — the
10807    /// `PgFlagStore`, `PgExperimentStore` and `PgConfigStore` open a
10808    /// `diesel::PgConnection` and issue `pg_notify` / `pg_advisory_xact_lock` /
10809    /// `jsonb` SQL. Handing one a `SQLite` target builds a store that fails on
10810    /// first use with a connection error naming a driver the operator never
10811    /// chose. This accessor is what those constructors screen on, so an
10812    /// unsupported target yields "no store" at construction instead.
10813    ///
10814    /// Fails closed: a target [`DatabaseBackend::detect`] cannot classify is
10815    /// refused too, rather than handed to a Postgres driver on the chance that
10816    /// it might work.
10817    #[must_use]
10818    pub fn effective_primary_postgres_url(&self) -> Option<&str> {
10819        self.effective_primary_url()
10820            .filter(|url| DatabaseBackend::detect(url) == Some(DatabaseBackend::Postgres))
10821    }
10822
10823    /// Resolved primary/write role pool size.
10824    #[must_use]
10825    pub fn effective_primary_pool_size(&self) -> usize {
10826        self.primary_pool_size.unwrap_or(self.pool_size)
10827    }
10828
10829    /// Resolved read/replica role pool size.
10830    #[must_use]
10831    pub fn effective_replica_pool_size(&self) -> usize {
10832        self.replica_pool_size.unwrap_or(self.pool_size)
10833    }
10834
10835    /// Whether any `[[database.shards]]` entries are configured.
10836    #[must_use]
10837    pub const fn has_shards(&self) -> bool {
10838        !self.shards.is_empty()
10839    }
10840
10841    /// Resolve the slot→shard map: element `s` is the index (into
10842    /// [`shards`](Self::shards)) of the shard that owns slot `s`.
10843    ///
10844    /// This is the single source of truth for slot assignment, used by both
10845    /// configuration validation and runtime
10846    /// [`ShardSet`](crate::sharding::ShardSet) construction:
10847    ///
10848    /// - When **no** shard declares `slots`, the slot space is auto-split
10849    ///   into contiguous even ranges by declaration order.
10850    /// - When **every** shard declares `slots`, the explicit assignments are
10851    ///   used and must cover <code>0..[SLOT_COUNT]</code> exactly once.
10852    /// - Mixing declared and undeclared `slots` is an error.
10853    ///
10854    /// # Errors
10855    ///
10856    /// Returns [`ConfigError::Validation`] for mixed declarations,
10857    /// malformed/out-of-range/duplicate slots, or incomplete coverage.
10858    pub fn resolved_slot_map(&self) -> Result<Vec<usize>, ConfigError> {
10859        let slot_count = usize::from(SLOT_COUNT);
10860
10861        if self.shards.is_empty() {
10862            return Ok(Vec::new());
10863        }
10864
10865        let declared = self.shards.iter().filter(|s| s.slots.is_some()).count();
10866        if declared != 0 && declared != self.shards.len() {
10867            return Err(ConfigError::Validation(
10868                "database.shards: either every shard must declare `slots` or none may \
10869                 (mixing explicit and auto-assigned slots is ambiguous)"
10870                    .to_owned(),
10871            ));
10872        }
10873
10874        if declared == 0 {
10875            // Contiguous even auto-split by declaration order.
10876            if self.shards.len() > slot_count {
10877                return Err(ConfigError::Validation(format!(
10878                    "database.shards: at most {slot_count} shards are supported \
10879                     (one per logical slot), got {}",
10880                    self.shards.len()
10881                )));
10882            }
10883            let n = self.shards.len();
10884            return Ok((0..slot_count).map(|slot| slot * n / slot_count).collect());
10885        }
10886
10887        let mut map: Vec<Option<usize>> = vec![None; slot_count];
10888        for (idx, shard) in self.shards.iter().enumerate() {
10889            let specs = shard.slots.as_deref().unwrap_or_default();
10890            for spec in specs {
10891                let slots = spec.expand().map_err(|e| {
10892                    ConfigError::Validation(format!("database.shards[{idx}].slots: {e}"))
10893                })?;
10894                for slot in slots {
10895                    if usize::from(slot) >= slot_count {
10896                        return Err(ConfigError::Validation(format!(
10897                            "database.shards[{idx}].slots: slot {slot} is out of range \
10898                             (slots are 0..{slot_count})"
10899                        )));
10900                    }
10901                    if let Some(owner) = map[usize::from(slot)] {
10902                        return Err(ConfigError::Validation(format!(
10903                            "database.shards[{idx}].slots: slot {slot} is already owned \
10904                             by shard {:?}",
10905                            self.shards[owner].name
10906                        )));
10907                    }
10908                    map[usize::from(slot)] = Some(idx);
10909                }
10910            }
10911        }
10912        let unassigned: Vec<usize> = map
10913            .iter()
10914            .enumerate()
10915            .filter_map(|(slot, owner)| owner.is_none().then_some(slot))
10916            .collect();
10917        if !unassigned.is_empty() {
10918            return Err(ConfigError::Validation(format!(
10919                "database.shards: slot map must cover every slot in 0..{slot_count}; \
10920                 unassigned slots: {}",
10921                format_slot_ranges(&unassigned)
10922            )));
10923        }
10924        // Coverage was just verified, so flatten cannot drop entries.
10925        Ok(map.into_iter().flatten().collect())
10926    }
10927
10928    /// Whether all shards are using auto-split (no shard declares `slots`).
10929    ///
10930    /// Returns `false` when no shards are configured or any shard has an
10931    /// explicit `slots` declaration. Mixed declarations already error in
10932    /// [`resolved_slot_map`](Self::resolved_slot_map), so this is a simple
10933    /// all-or-none check.
10934    #[must_use]
10935    pub fn shards_auto_split(&self) -> bool {
10936        self.has_shards() && self.shards.iter().all(|s| s.slots.is_none())
10937    }
10938
10939    /// Resolve the per-shard slot assignment as compact range strings.
10940    ///
10941    /// Inverts [`resolved_slot_map`](Self::resolved_slot_map) (slot→shard-index)
10942    /// into per-shard slot lists rendered via the same compact range notation
10943    /// used in slot-map error messages. Agrees with runtime routing by
10944    /// construction: the output derives from the same slot map that builds the
10945    /// live [`ShardSet`](crate::sharding::ShardSet).
10946    ///
10947    /// # Errors
10948    ///
10949    /// Propagates any [`ConfigError`] from `resolved_slot_map`.
10950    pub fn resolved_shard_assignments(&self) -> Result<Vec<ShardSlotAssignment>, ConfigError> {
10951        let slot_map = self.resolved_slot_map()?;
10952        let n = self.shards.len();
10953        let mut per_shard: Vec<Vec<usize>> = vec![Vec::new(); n];
10954        for (slot, &owner) in slot_map.iter().enumerate() {
10955            per_shard[owner].push(slot);
10956        }
10957        Ok(self
10958            .shards
10959            .iter()
10960            .enumerate()
10961            .map(|(idx, shard)| ShardSlotAssignment {
10962                name: shard.name.clone(),
10963                ranges: format_slot_ranges(&per_shard[idx]),
10964            })
10965            .collect())
10966    }
10967
10968    /// Cross-backend consistency checks (issue #1614).
10969    ///
10970    /// `SQLite` is a valid *target* but a narrower runtime than Postgres, so
10971    /// several Postgres-only knobs are refused at boot (not at first query)
10972    /// with actionable messages. The Postgres path is behaviourally unchanged:
10973    /// a Postgres primary with Postgres roles and no `SQLite` anywhere hits none
10974    /// of these branches.
10975    fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
10976        // Single source of truth: delegate to the free
10977        // [`database_backend_consistency`] rule so boot and out-of-process
10978        // callers (e.g. `autumn doctor`) agree for every role/backend mismatch.
10979        database_backend_consistency(
10980            self.url.as_deref(),
10981            self.primary_url.as_deref(),
10982            self.replica_url.as_deref(),
10983            !self.shards.is_empty(),
10984        )
10985        .map_err(ConfigError::Validation)
10986    }
10987
10988    /// Validate database configuration.
10989    ///
10990    /// # Errors
10991    ///
10992    /// Returns a validation error if a connection string is malformed or a
10993    /// shard declaration is malformed.
10994    pub fn validate(&self) -> Result<(), ConfigError> {
10995        for (field, url) in [
10996            ("database.url", self.url.as_deref()),
10997            ("database.primary_url", self.primary_url.as_deref()),
10998            ("database.replica_url", self.replica_url.as_deref()),
10999        ] {
11000            // A SQLite target (issue #1614) is now a recognized shape and
11001            // passes this per-field check; only strings that are neither a
11002            // Postgres nor a SQLite target are rejected here. The message's
11003            // GUIDANCE half is unchanged — everything up to "got" — so existing
11004            // diagnostics that match on it still match; the target it quotes is
11005            // now redacted (see below), because a rejected string is by
11006            // definition one whose secrets this code cannot enumerate.
11007            if let Some(url) = url
11008                && DatabaseBackend::detect(url).is_none()
11009            {
11010                let label = if field == "database.url" {
11011                    "database URL"
11012                } else {
11013                    field
11014                };
11015                // Redacted: this is the refusal a normal `autumn.toml`
11016                // misconfiguration hits, and it reaches `tracing::error!` at
11017                // boot — one line after the startup summary masked the same
11018                // URL. A target this branch could not classify is, by
11019                // definition, not a shape we can enumerate the secrets of.
11020                let target = crate::db_url::redact_target(url);
11021                return Err(ConfigError::Validation(format!(
11022                    "Invalid {label}: must start with postgres:// or postgresql://, or be a \
11023                     keyword/value connection string \
11024                     (e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
11025                )));
11026            }
11027        }
11028
11029        if self.replica_url.is_some() && self.effective_primary_url().is_none() {
11030            return Err(ConfigError::Validation(
11031                "database.replica_url requires database.primary_url or database.url".to_owned(),
11032            ));
11033        }
11034
11035        self.validate_backend_consistency()?;
11036
11037        let mut seen_names = std::collections::HashSet::new();
11038        for (idx, shard) in self.shards.iter().enumerate() {
11039            if shard.name.is_empty() {
11040                return Err(ConfigError::Validation(format!(
11041                    "database.shards[{idx}].name must not be empty"
11042                )));
11043            }
11044            if !shard
11045                .name
11046                .chars()
11047                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
11048            {
11049                return Err(ConfigError::Validation(format!(
11050                    "database.shards[{idx}].name {:?} is invalid: shard names are used in \
11051                     metric tags and health component names and must match [a-z0-9_-]",
11052                    shard.name
11053                )));
11054            }
11055            if !seen_names.insert(shard.name.as_str()) {
11056                return Err(ConfigError::Validation(format!(
11057                    "database.shards[{idx}].name {:?} is declared more than once; \
11058                     shard names must be unique",
11059                    shard.name
11060                )));
11061            }
11062            for (field, url) in [
11063                ("primary_url", Some(shard.primary_url.as_str())),
11064                ("replica_url", shard.replica_url.as_deref()),
11065            ] {
11066                if let Some(url) = url
11067                    && !is_pg_connection_string(url)
11068                {
11069                    let target = crate::db_url::redact_target(url);
11070                    return Err(ConfigError::Validation(format!(
11071                        "Invalid database.shards[{idx}].{field}: must start with \
11072                         postgres:// or postgresql://, or be a keyword/value \
11073                         connection string \
11074                         (e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
11075                    )));
11076                }
11077            }
11078        }
11079        self.resolved_slot_map()?;
11080        self.validate_fleet()?;
11081        Ok(())
11082    }
11083
11084    /// Cross-section rules for `[database.fleet]` (ADR 0019).
11085    fn validate_fleet(&self) -> Result<(), ConfigError> {
11086        let Some(fleet) = &self.fleet else {
11087            return Ok(());
11088        };
11089        fleet.validate()?;
11090        if self.has_shards() {
11091            return Err(ConfigError::Validation(
11092                "database.fleet and [[database.shards]] are both configured; a fleet routes \
11093                 tenant data to its own SQLite files, so declare one or the other"
11094                    .to_owned(),
11095            ));
11096        }
11097        if self.directory_shard_router {
11098            return Err(ConfigError::Validation(
11099                "database.directory_shard_router is set with database.fleet; the directory \
11100                 router is a Postgres control table, and a fleet routes by its mode"
11101                    .to_owned(),
11102            ));
11103        }
11104        let control = self.effective_primary_url();
11105        if control.and_then(DatabaseBackend::detect) != Some(DatabaseBackend::Sqlite)
11106            || control.is_some_and(is_in_memory_sqlite_target)
11107        {
11108            return Err(ConfigError::Validation(
11109                "database.fleet needs a file-backed sqlite: control database in database.url; \
11110                 framework state (sessions, jobs, flags) lives there while tenant data lives \
11111                 in the fleet"
11112                    .to_owned(),
11113            ));
11114        }
11115        if let Some(control_file) = control.and_then(sqlite_url_file) {
11116            let root = lexical_absolute(Path::new(&fleet.root));
11117            if control_file.starts_with(&root) {
11118                return Err(ConfigError::Validation(format!(
11119                    "the control database {} is inside database.fleet.root {}; a tenant or \
11120                     slot path could resolve to it and migrate it as a tenant. Put the \
11121                     control database outside the fleet root",
11122                    control_file.display(),
11123                    root.display()
11124                )));
11125            }
11126        }
11127        Ok(())
11128    }
11129}
11130
11131/// The file a file-backed `sqlite:` URL names, made absolute without touching
11132/// the file system. `None` for an in-memory target. Mirrors how the pool
11133/// normalizes a target (`sqlite://`, `sqlite:` or `file:`, query dropped).
11134pub(crate) fn sqlite_url_file(url: &str) -> Option<PathBuf> {
11135    if is_in_memory_sqlite_target(url) {
11136        return None;
11137    }
11138    let rest = url
11139        .strip_prefix("sqlite://")
11140        .or_else(|| url.strip_prefix("sqlite:"))
11141        .unwrap_or(url);
11142    // A URI path ends at `?` or `#`, and `SQLite` percent-decodes it
11143    // (`%66leet` is `fleet`). After `//` comes an authority: empty
11144    // (`file:///abs`) or `localhost` names this host; `SQLite` refuses any
11145    // other, so there is no local file to compare.
11146    // Any other spelling reaches `sqlite3_open` as a literal path: `?` and `#`
11147    // are filename characters there, not a query.
11148    let path = rest.strip_prefix("file:").map_or_else(
11149        || rest.to_owned(),
11150        |uri| {
11151            let uri = uri.strip_prefix("//").map_or(uri, |authority_and_path| {
11152                let (authority, path) = authority_and_path.split_at(
11153                    authority_and_path
11154                        .find('/')
11155                        .unwrap_or(authority_and_path.len()),
11156                );
11157                if authority.is_empty() || authority.eq_ignore_ascii_case("localhost") {
11158                    path
11159                } else {
11160                    ""
11161                }
11162            });
11163            percent_decode_uri_path(uri.split(['?', '#']).next().unwrap_or_default())
11164        },
11165    );
11166    (!path.is_empty()).then(|| lexical_absolute(Path::new(&path)))
11167}
11168
11169/// `SQLite`'s percent-decoding of a URI path: `%XX` becomes that byte, a `%`
11170/// not followed by two hex digits stays literal, and a decoded NUL ends the
11171/// path. Mirrors `crate::db::percent_decode`, which is `sqlite`-gated while
11172/// config validation is not.
11173fn percent_decode_uri_path(input: &str) -> String {
11174    let bytes = input.as_bytes();
11175    let mut out = Vec::with_capacity(bytes.len());
11176    let mut i = 0;
11177    while i < bytes.len() {
11178        let decoded = (bytes[i] == b'%')
11179            .then(|| bytes.get(i + 1..i + 3))
11180            .flatten()
11181            .filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
11182            .and_then(|hex| std::str::from_utf8(hex).ok())
11183            .and_then(|hex| u8::from_str_radix(hex, 16).ok());
11184        match decoded {
11185            Some(0) => break,
11186            Some(byte) => {
11187                out.push(byte);
11188                i += 3;
11189            }
11190            None => {
11191                out.push(bytes[i]);
11192                i += 1;
11193            }
11194        }
11195    }
11196    String::from_utf8_lossy(&out).into_owned()
11197}
11198
11199/// `path` made absolute against the working directory, with `.` and `..`
11200/// resolved lexically (no symlink resolution, no file system access).
11201fn lexical_absolute(path: &Path) -> PathBuf {
11202    let joined = if path.is_absolute() {
11203        path.to_path_buf()
11204    } else {
11205        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
11206    };
11207    let mut out = PathBuf::new();
11208    for part in joined.components() {
11209        match part {
11210            std::path::Component::CurDir => {}
11211            std::path::Component::ParentDir => {
11212                out.pop();
11213            }
11214            other => out.push(other),
11215        }
11216    }
11217    out
11218}
11219
11220/// Whether `s` is an acceptable Postgres connection string: a
11221/// `postgres://`/`postgresql://` URL, or a libpq-style keyword/value string
11222/// (`host=db user=app sslmode=require`) — recognized with the SAME parser
11223/// the pool's TLS module uses ([`crate::pg_conn_str`]), so every string the
11224/// pool supports also passes config validation (issue #1585 review: the
11225/// keyword form was rejected here before ever reaching the pool).
11226fn is_pg_connection_string(s: &str) -> bool {
11227    crate::pg_conn_str::is_url(s) || crate::pg_conn_str::is_keyword_value(s)
11228}
11229
11230/// Logging configuration.
11231///
11232/// Controls the tracing subscriber's filter level and output format.
11233/// See [`LogFormat`] for output format options.
11234///
11235/// # Examples
11236///
11237/// ```rust
11238/// use autumn_web::config::{LogConfig, LogFormat};
11239///
11240/// let log = LogConfig::default();
11241/// assert_eq!(log.level, "info");
11242/// assert_eq!(log.format, LogFormat::Auto);
11243/// assert!(log.access_log);
11244/// ```
11245#[derive(Debug, Clone, Deserialize)]
11246pub struct LogConfig {
11247    /// Tracing filter directive. Default: `"info"`.
11248    ///
11249    /// Supports the full `tracing` filter syntax, e.g.
11250    /// `"autumn=debug,tower_http=trace"`.
11251    #[serde(default = "default_log_level")]
11252    pub level: String,
11253
11254    /// Log output format. Default: [`LogFormat::Auto`].
11255    #[serde(default)]
11256    pub format: LogFormat,
11257
11258    /// Additional sensitive parameter keys to scrub from logs/traces.
11259    #[serde(default)]
11260    pub filter_parameters: Vec<String>,
11261
11262    /// Explicitly remove default sensitive keys from the built-in deny-list.
11263    #[serde(default)]
11264    pub unfilter_parameters: Vec<String>,
11265
11266    /// Emit one structured access-log event per served HTTP request.
11267    /// Default: `true`.
11268    ///
11269    /// The event (target `autumn::access`, level `INFO`) carries `method`,
11270    /// `route` (the matched low-cardinality template), `status`,
11271    /// `duration_ms`, and `request_id`, and is rendered by the standard
11272    /// subscriber according to [`format`](Self::format). It requires no
11273    /// telemetry feature or collector.
11274    #[serde(default = "default_access_log")]
11275    pub access_log: bool,
11276
11277    /// Path prefixes excluded from access logging so steady-state probe and
11278    /// asset traffic does not drown application signal. Default:
11279    /// `["/health", "/live", "/ready", "/startup", "/actuator", "/static"]`
11280    /// (the built-in probe, actuator, and static-asset mounts).
11281    ///
11282    /// Prefixes match whole path segments: `"/actuator"` excludes
11283    /// `/actuator/health` but not `/actuators`. Setting this replaces the
11284    /// default set entirely — and if you move the probe endpoints
11285    /// (`health.path` etc.), mirror the new paths here.
11286    #[serde(default = "default_access_log_exclude")]
11287    pub access_log_exclude: Vec<String>,
11288
11289    /// In-memory log capture buffer for `/actuator/logfile`.
11290    ///
11291    /// When enabled, recent structured log entries are visible over HTTP
11292    /// through the sensitive actuator endpoint without SSH access or an
11293    /// external log aggregator.  The buffer is bounded and never grows
11294    /// unbounded.
11295    #[serde(default)]
11296    pub capture: crate::log::capture::LogCaptureConfig,
11297}
11298
11299/// Log output format.
11300///
11301/// Controls how tracing events are rendered. The default ([`Auto`](Self::Auto))
11302/// auto-detects based on the `AUTUMN_ENV` environment variable.
11303///
11304/// | Variant | Behaviour |
11305/// |---------|-----------|
11306/// | [`Auto`](Self::Auto) | Pretty in dev, JSON when `AUTUMN_ENV=production` |
11307/// | [`Pretty`](Self::Pretty) | Always human-readable, colorized |
11308/// | [`Json`](Self::Json) | Always structured JSON (for log aggregators) |
11309///
11310/// # Examples
11311///
11312/// ```rust
11313/// use autumn_web::config::LogFormat;
11314///
11315/// assert_eq!(LogFormat::default(), LogFormat::Auto);
11316/// ```
11317#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
11318#[non_exhaustive]
11319pub enum LogFormat {
11320    /// Pretty in dev, JSON in production (based on `AUTUMN_ENV`).
11321    #[default]
11322    Auto,
11323    /// Human-readable, colorized output.
11324    Pretty,
11325    /// Structured JSON output suitable for log aggregation pipelines.
11326    Json,
11327}
11328
11329/// Telemetry configuration.
11330///
11331/// Controls whether Autumn enables OTLP trace export and how the process
11332/// identifies itself in resource metadata.
11333#[derive(Debug, Clone, Deserialize)]
11334pub struct TelemetryConfig {
11335    /// Enable framework-managed telemetry. Default: `false`.
11336    #[serde(default)]
11337    pub enabled: bool,
11338
11339    /// Logical service name. Default: `"autumn-app"`.
11340    #[serde(default = "default_telemetry_service_name")]
11341    pub service_name: String,
11342
11343    /// Optional service namespace (e.g. team, domain, or product family).
11344    #[serde(default)]
11345    pub service_namespace: Option<String>,
11346
11347    /// Service version string advertised in resource metadata.
11348    #[serde(default = "default_telemetry_service_version")]
11349    pub service_version: String,
11350
11351    /// Deployment environment label for trace resource metadata.
11352    #[serde(default = "default_telemetry_environment")]
11353    pub environment: String,
11354
11355    /// OTLP collector endpoint. Required when telemetry is enabled.
11356    #[serde(default)]
11357    pub otlp_endpoint: Option<String>,
11358
11359    /// OTLP transport protocol. Default: [`TelemetryProtocol::Grpc`].
11360    #[serde(default)]
11361    pub protocol: TelemetryProtocol,
11362
11363    /// When `true`, telemetry initialization failures abort startup.
11364    #[serde(default)]
11365    pub strict: bool,
11366
11367    /// Fraction of new traces to sample, from `0.0` to `1.0`. Default: `1.0`.
11368    ///
11369    /// The sampler is parent-based: a span with a sampled parent is sampled,
11370    /// and a span with an unsampled parent is not. The ratio applies only to
11371    /// root spans. A value out of range is clamped. `NaN` reads as `1.0`.
11372    #[serde(default = "default_telemetry_sample_ratio")]
11373    pub sample_ratio: f64,
11374}
11375
11376const fn default_telemetry_sample_ratio() -> f64 {
11377    1.0
11378}
11379
11380/// OTLP transport protocol selection.
11381#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
11382#[non_exhaustive]
11383pub enum TelemetryProtocol {
11384    /// OTLP over gRPC.
11385    #[serde(alias = "grpc", alias = "GRPC")]
11386    #[default]
11387    Grpc,
11388    /// OTLP over HTTP/protobuf.
11389    #[serde(
11390        alias = "http-protobuf",
11391        alias = "http_protobuf",
11392        alias = "HTTP_PROTOBUF"
11393    )]
11394    HttpProtobuf,
11395}
11396
11397impl TelemetryProtocol {
11398    fn from_env_value(value: &str) -> Option<Self> {
11399        match value {
11400            "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
11401            "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
11402            | "httpprotobuf" => Some(Self::HttpProtobuf),
11403            _ => None,
11404        }
11405    }
11406}
11407
11408/// Health check endpoint configuration.
11409///
11410/// The health check is automatically mounted by [`AppBuilder::run`](crate::app::AppBuilder::run).
11411/// See the [`health`](crate::health) module for response format details.
11412///
11413/// # Examples
11414///
11415/// ```rust
11416/// use autumn_web::config::HealthConfig;
11417///
11418/// let health = HealthConfig::default();
11419/// assert!(health.enabled);
11420/// assert_eq!(health.path, "/health");
11421/// assert_eq!(health.live_path, "/live");
11422/// assert_eq!(health.ready_path, "/ready");
11423/// assert_eq!(health.startup_path, "/startup");
11424/// assert!(!health.detailed);
11425/// assert_eq!(health.cache_ttl_ms, 1_000);
11426/// assert_eq!(health.ping_timeout_ms, 2_000);
11427/// assert!(health.db_readiness);
11428/// assert!(!health.redis_readiness);
11429/// ```
11430#[allow(
11431    clippy::struct_excessive_bools,
11432    reason = "independent on/off switches of one config section"
11433)]
11434#[derive(Debug, Clone, Deserialize)]
11435pub struct HealthConfig {
11436    /// When `true` (the default), the framework auto-mounts the built-in
11437    /// probe endpoints (health/live/ready/startup). Set to `false` to
11438    /// suppress all built-in probes so an app can own those paths entirely
11439    /// (or expose none at all). Default: `true` (issue #1971).
11440    #[serde(default = "default_health_enabled")]
11441    pub enabled: bool,
11442
11443    /// Compatibility alias path for readiness. Default: `"/health"`.
11444    ///
11445    /// Common alternatives: `"/healthz"`, `"/_health"`.
11446    #[serde(default = "default_health_path")]
11447    pub path: String,
11448
11449    /// URL path for the liveness probe. Default: `"/live"`.
11450    #[serde(default = "default_live_path")]
11451    pub live_path: String,
11452
11453    /// URL path for the readiness probe. Default: `"/ready"`.
11454    #[serde(default = "default_ready_path")]
11455    pub ready_path: String,
11456
11457    /// URL path for the startup probe. Default: `"/startup"`.
11458    #[serde(default = "default_startup_path")]
11459    pub startup_path: String,
11460
11461    /// When `true`, the health endpoint includes detailed info (profile,
11462    /// uptime, pool stats). Default: `false` (overridden to `true` for
11463    /// `dev` profile via smart defaults).
11464    #[serde(default)]
11465    pub detailed: bool,
11466
11467    /// How long one dependency check result stays valid, in milliseconds.
11468    /// Applies to the database pings and to each registered health indicator.
11469    /// When a result is stale, one probe refreshes it. The other probes wait
11470    /// for that result. `0` turns the cache off. Default: `1000`.
11471    #[serde(default = "default_health_cache_ttl_ms")]
11472    pub cache_ttl_ms: u64,
11473
11474    /// Time limit for one built-in dependency ping (database `SELECT 1`,
11475    /// Redis `PING`), in milliseconds. A ping that does not finish in time is
11476    /// `DOWN`. Keep it below the probe timeout of the platform. Must not be
11477    /// `0`. Default: `2000`.
11478    #[serde(default = "default_health_ping_timeout_ms")]
11479    pub ping_timeout_ms: u64,
11480
11481    /// When `true`, a failed primary database ping makes `/ready` return
11482    /// `503`. When `false`, only `/actuator/health` shows it. Default: `true`.
11483    #[serde(default = "default_health_db_readiness")]
11484    pub db_readiness: bool,
11485
11486    /// When `true`, the built-in Redis indicators gate `/ready`. The default
11487    /// is `false`: they show in `/actuator/health` only. All replicas share
11488    /// Redis. A Redis failure that gates `/ready` removes every replica from
11489    /// rotation.
11490    #[serde(default)]
11491    pub redis_readiness: bool,
11492}
11493
11494impl HealthConfig {
11495    /// [`Self::cache_ttl_ms`] as a [`Duration`](std::time::Duration).
11496    #[must_use]
11497    pub const fn cache_ttl(&self) -> std::time::Duration {
11498        std::time::Duration::from_millis(self.cache_ttl_ms)
11499    }
11500
11501    /// [`Self::ping_timeout_ms`] as a [`Duration`](std::time::Duration).
11502    #[must_use]
11503    pub const fn ping_timeout(&self) -> std::time::Duration {
11504        std::time::Duration::from_millis(self.ping_timeout_ms)
11505    }
11506
11507    /// Check the values.
11508    ///
11509    /// # Errors
11510    ///
11511    /// Returns [`ConfigError::Validation`] when `ping_timeout_ms` is `0`.
11512    pub fn validate(&self) -> Result<(), ConfigError> {
11513        if self.ping_timeout_ms == 0 {
11514            return Err(ConfigError::Validation(
11515                "health.ping_timeout_ms must be greater than 0: a zero limit fails every \
11516                 database and Redis ping, so /ready never returns 200"
11517                    .to_owned(),
11518            ));
11519        }
11520        Ok(())
11521    }
11522}
11523
11524const fn default_health_db_readiness() -> bool {
11525    true
11526}
11527
11528const fn default_health_cache_ttl_ms() -> u64 {
11529    1_000
11530}
11531
11532const fn default_health_ping_timeout_ms() -> u64 {
11533    2_000
11534}
11535
11536/// Actuator endpoint configuration.
11537///
11538/// Controls which operational endpoints are exposed. The `sensitive` flag
11539/// determines whether sensitive endpoints (env, configprops, loggers,
11540/// tasks) are available. Defaults to `true` for `dev`, `false` for `prod`.
11541#[derive(Debug, Clone, Deserialize)]
11542pub struct ActuatorConfig {
11543    /// URL prefix for actuator endpoints. Default: `"/actuator"`.
11544    #[serde(default = "default_actuator_prefix")]
11545    pub prefix: String,
11546
11547    /// When `true`, expose sensitive endpoints (env, loggers, tasks).
11548    /// Defaults vary by profile: `true` for dev, `false` for prod.
11549    #[serde(default)]
11550    pub sensitive: bool,
11551
11552    /// When `true`, mount the `/actuator/prometheus` scrape endpoint.
11553    ///
11554    /// This is **independent of [`Self::sensitive`]**: a production app can
11555    /// expose Prometheus metrics for platform scraping (e.g. Fly.io `[metrics]`)
11556    /// while keeping `sensitive = false` so env/configprops/loggers/tasks/jobs
11557    /// stay off the public surface. Set to `false` to remove the scrape
11558    /// endpoint entirely (it then returns `404`). Default: `true`.
11559    #[serde(default = "default_actuator_prometheus")]
11560    pub prometheus: bool,
11561}
11562
11563impl Default for ActuatorConfig {
11564    fn default() -> Self {
11565        Self {
11566            prefix: default_actuator_prefix(),
11567            sensitive: false,
11568            prometheus: default_actuator_prometheus(),
11569        }
11570    }
11571}
11572
11573fn default_actuator_prefix() -> String {
11574    "/actuator".to_owned()
11575}
11576
11577const fn default_actuator_prometheus() -> bool {
11578    true
11579}
11580
11581/// App-metric registry limits (`[metrics]` section).
11582///
11583/// These are the three cardinality caps the call-site metrics facade
11584/// ([`crate::metrics`]) enforces on an app's own counters, gauges, histograms
11585/// and timers. They exist so a label-cardinality mistake degrades the metric
11586/// instead of growing memory without bound; they are configurable because the
11587/// line between "a mistake" and "a large but deliberate label space" is a
11588/// property of the app, not of the framework.
11589///
11590/// Every key defaults to the value the facade shipped with in 0.7.0, so an
11591/// app with no `[metrics]` section is unaffected.
11592///
11593/// # Raising a cap is not free
11594///
11595/// A retained series is never evicted — evicting a counter would reset it, and
11596/// a reset is indistinguishable from a restart to `rate()`. Raising
11597/// `max_series_per_metric` therefore raises the *permanent* memory an
11598/// unbounded label value (a user id, a URL, an error string) can claim.
11599/// Raise it because the app's label space is genuinely larger, not to silence
11600/// `autumn_metrics_series_dropped_total`.
11601///
11602/// # Examples
11603///
11604/// ```toml
11605/// [metrics]
11606/// max_series_per_metric = 500
11607/// max_instruments = 512
11608/// ```
11609///
11610/// Each key also takes an environment override:
11611/// `AUTUMN_METRICS__MAX_SERIES_PER_METRIC`,
11612/// `AUTUMN_METRICS__MAX_INSTRUMENTS`,
11613/// `AUTUMN_METRICS__MAX_LABELS_PER_SERIES`.
11614///
11615/// # Where it is applied
11616///
11617/// `AppBuilder::run` installs this section process-wide before it builds
11618/// anything from the config, so it is in force before the first call site can
11619/// record. The registry is process-global, so
11620/// [`TestApp`](crate::test::TestApp) deliberately does **not** apply it — one
11621/// test's caps would leak into every test sharing the binary. A test that needs
11622/// a different cap calls [`crate::metrics::set_limits`] itself and restores the
11623/// default afterwards.
11624///
11625/// See `docs/guide/metrics.md`.
11626#[derive(Debug, Clone, Deserialize)]
11627pub struct MetricsConfig {
11628    /// Distinct **labeled** series retained per instrument. Default: `100`.
11629    ///
11630    /// Beyond this, a sample carrying a label set the instrument has not seen
11631    /// is dropped and counted in `autumn_metrics_series_dropped_total`. The
11632    /// unlabeled series does not count against it. Range: 1 to
11633    /// [`MAX_SERIES_PER_METRIC_CEILING`](crate::metrics::MAX_SERIES_PER_METRIC_CEILING).
11634    #[serde(default = "default_max_series_per_metric")]
11635    pub max_series_per_metric: usize,
11636
11637    /// Distinct instruments the process-global registry holds. Default: `256`.
11638    ///
11639    /// Beyond this, a new metric name gets an inert handle. Range: 1 to
11640    /// [`MAX_INSTRUMENTS_CEILING`](crate::metrics::MAX_INSTRUMENTS_CEILING).
11641    #[serde(default = "default_max_instruments")]
11642    pub max_instruments: usize,
11643
11644    /// Labels retained on a single series. Default: `8`.
11645    ///
11646    /// Extras are dropped — lexicographically smallest names survive — and
11647    /// the sample is still recorded. Range: 1 to
11648    /// [`MAX_LABELS_PER_SERIES_CEILING`](crate::metrics::MAX_LABELS_PER_SERIES_CEILING).
11649    ///
11650    /// Changing this changes **series identity**: the same twelve labels
11651    /// canonicalize to a different key under a cap of 8 than under 12. Set it
11652    /// once, before the app records anything.
11653    #[serde(default = "default_max_labels_per_series")]
11654    pub max_labels_per_series: usize,
11655}
11656
11657impl Default for MetricsConfig {
11658    fn default() -> Self {
11659        Self {
11660            max_series_per_metric: default_max_series_per_metric(),
11661            max_instruments: default_max_instruments(),
11662            max_labels_per_series: default_max_labels_per_series(),
11663        }
11664    }
11665}
11666
11667impl MetricsConfig {
11668    /// This section as the value the metrics registry consumes.
11669    #[must_use]
11670    pub const fn limits(&self) -> crate::metrics::Limits {
11671        crate::metrics::Limits {
11672            max_series_per_metric: self.max_series_per_metric,
11673            max_instruments: self.max_instruments,
11674            max_labels_per_series: self.max_labels_per_series,
11675        }
11676    }
11677
11678    /// Reject an out-of-range cap by name.
11679    ///
11680    /// # Errors
11681    ///
11682    /// Returns the offending `autumn.toml` key and its accepted range.
11683    pub fn validate(&self) -> Result<(), ConfigError> {
11684        self.limits().validate().map_err(ConfigError::Validation)
11685    }
11686}
11687
11688const fn default_max_series_per_metric() -> usize {
11689    crate::metrics::DEFAULT_MAX_SERIES_PER_METRIC
11690}
11691
11692const fn default_max_instruments() -> usize {
11693    crate::metrics::DEFAULT_MAX_INSTRUMENTS
11694}
11695
11696const fn default_max_labels_per_series() -> usize {
11697    crate::metrics::DEFAULT_MAX_LABELS_PER_SERIES
11698}
11699
11700/// CORS (Cross-Origin Resource Sharing) configuration.
11701///
11702/// Controls which origins, methods, and headers are allowed for
11703/// cross-origin requests. Disabled by default -- enable by setting
11704/// `allowed_origins` in `autumn.toml` or via environment variables.
11705///
11706/// # Defaults
11707///
11708/// | Field | Default |
11709/// |-------|---------|
11710/// | `allowed_origins` | `[]` (CORS disabled) |
11711/// | `allowed_methods` | `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]` |
11712/// | `allowed_headers` | `["Content-Type", "Authorization"]` |
11713/// | `allow_credentials` | `false` |
11714/// | `max_age_secs` | `86400` (24 hours) |
11715///
11716/// # Profile smart defaults
11717///
11718/// The `dev` profile enables permissive CORS (`allowed_origins = ["*"]`)
11719/// so local front-end development works out of the box.
11720///
11721/// # Examples
11722///
11723/// ```toml
11724/// [cors]
11725/// allowed_origins = ["https://example.com", "https://app.example.com"]
11726/// allow_credentials = true
11727/// ```
11728///
11729/// ```rust
11730/// use autumn_web::config::CorsConfig;
11731///
11732/// let cors = CorsConfig::default();
11733/// assert!(cors.allowed_origins.is_empty());
11734/// assert!(!cors.allow_credentials);
11735/// ```
11736#[derive(Debug, Clone, Deserialize)]
11737pub struct CorsConfig {
11738    /// Origins allowed to make cross-origin requests.
11739    ///
11740    /// Use `["*"]` to allow any origin (not recommended for production
11741    /// with credentials). When empty, CORS middleware is not applied.
11742    #[serde(default)]
11743    pub allowed_origins: Vec<String>,
11744
11745    /// HTTP methods allowed for cross-origin requests.
11746    /// Default: `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]`.
11747    #[serde(default = "default_cors_methods")]
11748    pub allowed_methods: Vec<String>,
11749
11750    /// Headers allowed in cross-origin requests.
11751    /// Default: `["Content-Type", "Authorization"]`.
11752    #[serde(default = "default_cors_headers")]
11753    pub allowed_headers: Vec<String>,
11754
11755    /// Whether to include `Access-Control-Allow-Credentials: true`.
11756    /// Default: `false`.
11757    #[serde(default)]
11758    pub allow_credentials: bool,
11759
11760    /// How long (in seconds) browsers may cache preflight responses.
11761    /// Default: `86400` (24 hours).
11762    #[serde(default = "default_cors_max_age")]
11763    pub max_age_secs: u64,
11764}
11765
11766impl Default for CorsConfig {
11767    fn default() -> Self {
11768        Self {
11769            allowed_origins: Vec::new(),
11770            allowed_methods: default_cors_methods(),
11771            allowed_headers: default_cors_headers(),
11772            allow_credentials: false,
11773            max_age_secs: default_cors_max_age(),
11774        }
11775    }
11776}
11777
11778impl CorsConfig {
11779    /// Validate CORS configuration for combinations rejected by browsers.
11780    ///
11781    /// # Errors
11782    ///
11783    /// Returns a validation error when `allow_credentials = true` is combined
11784    /// with a wildcard `"*"` origin. Browsers refuse this combination per the
11785    /// Fetch spec, and `tower-http`'s `CorsLayer` panics when asked to build
11786    /// it, so we fail fast at config load with an actionable message.
11787    pub fn validate(&self) -> Result<(), ConfigError> {
11788        if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
11789            return Err(ConfigError::Validation(
11790                "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
11791                 list explicit origins instead (browsers reject the wildcard+credentials combo)"
11792                    .to_owned(),
11793            ));
11794        }
11795        Ok(())
11796    }
11797}
11798
11799fn default_cors_methods() -> Vec<String> {
11800    vec![
11801        "GET".to_owned(),
11802        "POST".to_owned(),
11803        "PUT".to_owned(),
11804        "DELETE".to_owned(),
11805        "PATCH".to_owned(),
11806        "OPTIONS".to_owned(),
11807    ]
11808}
11809
11810fn default_cors_headers() -> Vec<String> {
11811    vec!["Content-Type".to_owned(), "Authorization".to_owned()]
11812}
11813
11814const fn default_cors_max_age() -> u64 {
11815    86400
11816}
11817
11818// ── CompressionConfig ──────────────────────────────────────────────────────
11819
11820/// Response compression settings (`[compression]` section in `autumn.toml`).
11821///
11822/// Compression is **off by default** to avoid the [BREACH/CRIME] class of
11823/// compression side-channel attacks, where an attacker can infer secret
11824/// content (e.g. CSRF tokens) by observing how the compressed size changes as
11825/// they inject attacker-controlled bytes alongside the secret. Enable only when
11826/// you understand the tradeoff — or when a CDN / reverse-proxy handles TLS and
11827/// terminates there.
11828///
11829/// [BREACH/CRIME]: https://breachattack.com/
11830///
11831/// # One-liner opt-in
11832///
11833/// ```toml
11834/// [compression]
11835/// enabled = true
11836/// ```
11837///
11838/// # Environment variable override
11839///
11840/// | Variable | Field | Type |
11841/// |----------|-------|------|
11842/// | `AUTUMN_COMPRESSION__ENABLED` | `enabled` | `bool` |
11843///
11844/// # `ETag` compatibility
11845///
11846/// Autumn's framework-managed compression layer is applied **outside** any
11847/// user-registered `EtagLayer`, so `ETags` are computed on the uncompressed body.
11848/// Because `CompressionLayer` sets `Vary: Accept-Encoding`, caches correctly
11849/// store separate entries per encoding. Using weak `ETags` (`W/`) when
11850/// compression is enabled is safe per RFC 7232 §2.1 (weak comparison allows
11851/// encoding variations).
11852///
11853/// # Example
11854///
11855/// ```rust
11856/// use autumn_web::config::CompressionConfig;
11857///
11858/// let cfg = CompressionConfig::default();
11859/// assert!(!cfg.enabled);
11860/// ```
11861#[derive(Debug, Clone, Deserialize, Default)]
11862pub struct CompressionConfig {
11863    /// Enable response compression. Default: `false`.
11864    ///
11865    /// When `true`, the framework inserts a `CompressionLayer` that honors the
11866    /// client's `Accept-Encoding` header (gzip and brotli supported) and sets
11867    /// `Vary: Accept-Encoding` on all compressible responses.
11868    /// Non-compressible content types (images, archives) and responses that
11869    /// already carry `Content-Encoding` are passed through unchanged.
11870    #[serde(default)]
11871    pub enabled: bool,
11872}
11873
11874/// Apply `AUTUMN_DEPLOY__*` environment overrides to an optional deploy config.
11875///
11876/// `[deploy]` is a top-level optional section. This materializes it from the
11877/// environment when any of its keys are set (seeding the documented defaults if
11878/// the section was absent/`None`), so a CI/VPS deploy can keep the target host
11879/// out of `autumn.toml` and drive it entirely through `AUTUMN_DEPLOY__*`. Env
11880/// overrides win over any TOML-provided values.
11881///
11882/// Shared by [`AutumnConfig::load`] and `autumn doctor`'s deploy preflight so
11883/// both surfaces resolve the identical deploy target (host, `ssh_port`, …) for the
11884/// same environment + profile + TOML.
11885// Exposed for autumn-cli's `autumn deploy` preflight (doctor) to reuse the deploy env-override logic; not yet a stable public API.
11886#[doc(hidden)]
11887pub fn apply_deploy_env_overrides(deploy: &mut Option<DeployConfig>, env: &dyn Env) {
11888    // This array is a PRESENCE PROBE gating materialization of the ENTIRE
11889    // `[deploy]` table: a key missing from it means an env-only config that sets
11890    // only that key produces no deploy section at all — a silent skip, not an
11891    // error, in both `AutumnConfig::load` and `autumn doctor`. Every key parsed
11892    // below MUST appear here.
11893    const KEYS: [&str; 18] = [
11894        "AUTUMN_DEPLOY__HOST",
11895        "AUTUMN_DEPLOY__HOSTS",
11896        "AUTUMN_DEPLOY__USER",
11897        "AUTUMN_DEPLOY__SSH_PORT",
11898        "AUTUMN_DEPLOY__APP_NAME",
11899        "AUTUMN_DEPLOY__APP_DIR",
11900        "AUTUMN_DEPLOY__SERVICE_NAME",
11901        "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
11902        "AUTUMN_DEPLOY__KEEP_RELEASES",
11903        "AUTUMN_DEPLOY__PROFILE",
11904        "AUTUMN_DEPLOY__TLS__ENABLED",
11905        "AUTUMN_DEPLOY__TLS__HOST",
11906        "AUTUMN_DEPLOY__INSTALL_PROXY",
11907        "AUTUMN_DEPLOY__BAKE__DURATION_SECS",
11908        "AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
11909        "AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
11910        "AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
11911        "AUTUMN_DEPLOY__BAKE__MAX_P99_MS",
11912    ];
11913    if !KEYS.iter().any(|key| env.var(key).is_ok()) {
11914        return;
11915    }
11916    let deploy = deploy.get_or_insert_with(DeployConfig::default);
11917    // #1621 review round 1: `host` and `hosts` are mutually exclusive downstream, so
11918    // applying one spelling from the environment on top of the other spelling in TOML
11919    // produced a config that refuses every `autumn deploy` subcommand — even though
11920    // `AUTUMN_DEPLOY__*` is documented to win over TOML. Whether each spelling was set
11921    // non-empty in the environment is therefore captured before either is applied, so a
11922    // non-empty env value can clear the TOML alternate below.
11923    //
11924    // "Non-empty" is judged conservatively: a value blank after trimming never clears
11925    // the other spelling, so the established empty-means-unset semantics survive.
11926    // `AUTUMN_DEPLOY__HOST=` and `AUTUMN_DEPLOY__HOSTS=`/`,`/` , ` are the shape a CI or
11927    // compose template emits for an unfilled slot, and they say nothing about the other
11928    // spelling. `parse_env_option_string` below still applies its own unchanged
11929    // `is_empty()` rule to `host` itself; trimming here only makes the clearing decision
11930    // stricter, so a whitespace-only value can never drop a configured fleet list.
11931    let env_set_host = env
11932        .var("AUTUMN_DEPLOY__HOST")
11933        .is_ok_and(|value| !value.trim().is_empty());
11934    let env_set_hosts = env
11935        .var("AUTUMN_DEPLOY__HOSTS")
11936        .is_ok_and(|value| value.split(',').any(|entry| !entry.trim().is_empty()));
11937    parse_env_option_string(env, "AUTUMN_DEPLOY__HOST", &mut deploy.host);
11938    // #1621: the fleet host list, as CSV. It REPLACES the whole TOML list (a
11939    // fleet-level retarget), matching every other `AUTUMN_DEPLOY__*` override.
11940    // Entries are trimmed by `parse_env_csv_non_empty`, which also DROPS blank
11941    // segments: `AUTUMN_DEPLOY__HOSTS=` means unset (as `AUTUMN_DEPLOY__HOST=`
11942    // does) and a trailing/doubled comma is tolerated, rather than reaching the
11943    // CLI as a blank fleet entry that refuses every deploy subcommand. Duplicate
11944    // entries are still rejected downstream by the CLI's `ResolvedFleet::resolve`.
11945    parse_env_csv_non_empty(env, "AUTUMN_DEPLOY__HOSTS", &mut deploy.hosts);
11946    // Env-over-TOML precedence, applied to the spelling the operator did not set:
11947    // retargeting a `[deploy] host` project as a fleet, or a `[deploy] hosts` project at
11948    // a single server, is a legitimate env override rather than a conflict. It is
11949    // deliberately not a tie-break: when both env spellings are set non-empty the
11950    // rollout order is genuinely ambiguous — an operator error, not a precedence
11951    // question — so both survive and the existing mutual-exclusion refusal
11952    // (`DeployConfig::validate`, the CLI's `deploy_host_list`) still fires, naming both
11953    // keys.
11954    if env_set_hosts && !env_set_host {
11955        deploy.host = None;
11956    } else if env_set_host && !env_set_hosts {
11957        deploy.hosts.clear();
11958    }
11959    parse_env_string(env, "AUTUMN_DEPLOY__USER", &mut deploy.user);
11960    parse_env(env, "AUTUMN_DEPLOY__SSH_PORT", &mut deploy.ssh_port);
11961    parse_env_option_string(env, "AUTUMN_DEPLOY__APP_NAME", &mut deploy.app_name);
11962    parse_env_option_string(env, "AUTUMN_DEPLOY__APP_DIR", &mut deploy.app_dir);
11963    parse_env_option_string(env, "AUTUMN_DEPLOY__SERVICE_NAME", &mut deploy.service_name);
11964    parse_env(
11965        env,
11966        "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
11967        &mut deploy.readiness_timeout_secs,
11968    );
11969    parse_env(
11970        env,
11971        "AUTUMN_DEPLOY__KEEP_RELEASES",
11972        &mut deploy.keep_releases,
11973    );
11974    parse_env_string(env, "AUTUMN_DEPLOY__PROFILE", &mut deploy.profile);
11975    // Opt-in TLS for the deploy-managed proxy (#1969). Env wins over TOML, matching
11976    // every other deploy override above.
11977    parse_env_bool(env, "AUTUMN_DEPLOY__TLS__ENABLED", &mut deploy.tls.enabled);
11978    parse_env_option_string(env, "AUTUMN_DEPLOY__TLS__HOST", &mut deploy.tls.host);
11979    // Host preparation opt-out (#1607). Env wins over TOML, like every other deploy
11980    // override above, so a CI pipeline deploying to pre-provisioned hosts can
11981    // decline it without editing `autumn.toml`.
11982    parse_env_bool(
11983        env,
11984        "AUTUMN_DEPLOY__INSTALL_PROXY",
11985        &mut deploy.install_proxy,
11986    );
11987    // The post-cutover bake (#3069). Env wins over TOML.
11988    let bake = &mut deploy.bake;
11989    parse_env(
11990        env,
11991        "AUTUMN_DEPLOY__BAKE__DURATION_SECS",
11992        &mut bake.duration_secs,
11993    );
11994    parse_env(
11995        env,
11996        "AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
11997        &mut bake.interval_secs,
11998    );
11999    parse_env(
12000        env,
12001        "AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
12002        &mut bake.min_requests,
12003    );
12004    parse_env_option(
12005        env,
12006        "AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
12007        &mut bake.max_error_rate,
12008    );
12009    parse_env_option(env, "AUTUMN_DEPLOY__BAKE__MAX_P99_MS", &mut bake.max_p99_ms);
12010}
12011
12012/// Parse an environment variable into a typed target, logging a warning on failure.
12013fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
12014    if let Ok(val) = env.var(key) {
12015        match val.parse::<T>() {
12016            Ok(v) => *target = v,
12017            Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
12018        }
12019    }
12020}
12021
12022fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
12023    if let Ok(val) = env.var(key) {
12024        *target = if val.is_empty() { None } else { Some(val) };
12025    }
12026}
12027
12028/// Secret-aware variant of [`parse_env_option_string`]: an empty (after
12029/// trimming) value clears the target, otherwise the trimmed value is wrapped in
12030/// a [`secrecy::SecretString`] so it is redacted from `Debug` and zeroized on
12031/// drop.
12032fn parse_env_option_secret(env: &dyn Env, key: &str, target: &mut Option<secrecy::SecretString>) {
12033    if let Ok(val) = env.var(key) {
12034        let trimmed = val.trim();
12035        *target = if trimmed.is_empty() {
12036            None
12037        } else {
12038            Some(secrecy::SecretString::from(trimmed.to_owned()))
12039        };
12040    }
12041}
12042
12043fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
12044    if let Ok(val) = env.var(key) {
12045        if val.is_empty() {
12046            *target = None;
12047        } else {
12048            match val.parse::<T>() {
12049                Ok(v) => *target = Some(v),
12050                Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
12051            }
12052        }
12053    }
12054}
12055
12056/// Parse a duration env var (`"30s"`, `"500ms"`, or bare milliseconds).
12057/// An empty value clears the target.
12058fn parse_env_option_duration(env: &dyn Env, key: &str, target: &mut Option<std::time::Duration>) {
12059    if let Ok(val) = env.var(key) {
12060        let val = val.trim();
12061        if val.is_empty() {
12062            *target = None;
12063        } else {
12064            match parse_duration_str(val) {
12065                Ok(d) => *target = Some(d),
12066                Err(e) => eprintln!("Warning: {key}={val:?} is not valid ({e}), ignoring"),
12067            }
12068        }
12069    }
12070}
12071
12072fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
12073    if let Ok(val) = env.var(key) {
12074        *target = val;
12075    }
12076}
12077
12078fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
12079    if let Ok(val) = env.var(key) {
12080        match val.as_str() {
12081            "true" | "1" => *target = true,
12082            "false" | "0" => *target = false,
12083            _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
12084        }
12085    }
12086}
12087
12088fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
12089    if let Ok(val) = env.var(key) {
12090        match val.as_str() {
12091            "true" | "1" => *target = Some(true),
12092            "false" | "0" => *target = Some(false),
12093            _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
12094        }
12095    }
12096}
12097
12098fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
12099    if let Ok(val) = env.var(key) {
12100        *target = val.split(',').map(|s| s.trim().to_owned()).collect();
12101    }
12102}
12103
12104/// CSV env override that drops blank segments (issue #1621).
12105///
12106/// Same shape as [`parse_env_csv`], but an empty/whitespace-only segment is not a
12107/// list entry. Two consequences, both deliberate:
12108///
12109/// * `KEY=` (or `KEY="   "`) means **unset**, i.e. the list is cleared rather than
12110///   set to a one-element vector holding a blank string. That is the shape a CI or
12111///   compose env template produces for a not-yet-filled-in value, and it matches
12112///   [`parse_env_option_string`]'s empty-is-unset rule for the sibling scalar keys
12113///   (and `crate::maintenance::flag_file_path_from`'s blank-is-unset rule).
12114/// * A trailing or doubled comma — routine in generated env lists — is tolerated
12115///   instead of surfacing downstream as a blank entry.
12116///
12117/// Used only for `AUTUMN_DEPLOY__HOSTS`, whose downstream consumer turns a blank
12118/// entry into a hard refusal of every `autumn deploy` subcommand; the other CSV
12119/// overrides keep [`parse_env_csv`]'s long-standing behaviour.
12120fn parse_env_csv_non_empty(env: &dyn Env, key: &str, target: &mut Vec<String>) {
12121    if let Ok(val) = env.var(key) {
12122        *target = val
12123            .split(',')
12124            .map(str::trim)
12125            .filter(|entry| !entry.is_empty())
12126            .map(ToOwned::to_owned)
12127            .collect();
12128    }
12129}
12130
12131// ── Default functions ──────────────────────────────────────────────
12132
12133const fn default_port() -> u16 {
12134    3000
12135}
12136
12137fn default_host() -> String {
12138    "127.0.0.1".to_owned()
12139}
12140
12141const fn default_shutdown_timeout() -> u64 {
12142    30
12143}
12144
12145const fn default_prestop_grace() -> u64 {
12146    5
12147}
12148
12149const fn default_pool_size() -> usize {
12150    10
12151}
12152
12153const fn default_migration_lock_timeout() -> std::time::Duration {
12154    std::time::Duration::from_secs(5)
12155}
12156
12157const fn default_migration_lock_retries() -> u32 {
12158    5
12159}
12160
12161/// Admitted requests per primary pool connection in the `prod` load-shedding
12162/// ceiling (#3057). The ceiling is `primary pool size × this value`, and at
12163/// least [`PROD_MIN_ADMISSION_LIMIT`].
12164///
12165/// Requests above the pool size wait for a connection, and a long queue only
12166/// adds latency before the `connect_timeout_secs` failure. 32 waiters per
12167/// connection is a high, safe ceiling. Tune it with
12168/// `server.max_concurrent_requests` or a capacity contract.
12169pub const PROD_REQUESTS_PER_POOL_CONNECTION: usize = 32;
12170
12171/// The lowest `prod` load-shedding ceiling (#3057). A small primary pool (a
12172/// sharded app, or an app that does not use its database much) must not shed
12173/// normal traffic.
12174pub const PROD_MIN_ADMISSION_LIMIT: usize = 256;
12175
12176const fn default_max_connections_warn_threshold() -> usize {
12177    100
12178}
12179
12180const fn default_connect_timeout() -> u64 {
12181    5
12182}
12183
12184const fn default_pin_after_write_secs() -> u64 {
12185    5
12186}
12187
12188fn default_log_level() -> String {
12189    "info".to_owned()
12190}
12191
12192const fn default_access_log() -> bool {
12193    true
12194}
12195
12196fn default_access_log_exclude() -> Vec<String> {
12197    vec![
12198        "/health".to_owned(),
12199        "/live".to_owned(),
12200        "/ready".to_owned(),
12201        "/startup".to_owned(),
12202        "/actuator".to_owned(),
12203        "/static".to_owned(),
12204    ]
12205}
12206
12207fn default_telemetry_service_name() -> String {
12208    "autumn-app".to_owned()
12209}
12210
12211fn default_telemetry_service_version() -> String {
12212    "unknown".to_owned()
12213}
12214
12215fn default_telemetry_environment() -> String {
12216    "development".to_owned()
12217}
12218
12219/// Default `[server.tls]` cert/key reload poll interval, in seconds.
12220///
12221/// Kept in lockstep with `crate::tls::DEFAULT_RELOAD_INTERVAL_SECS` (the
12222/// serving path's constant); a literal is used here because this default must
12223/// compile even when the `tls` feature — and thus `crate::tls` — is off.
12224const fn default_tls_reload_interval_secs() -> u64 {
12225    60
12226}
12227
12228/// Default `[server.tls]` inbound-handshake timeout, in seconds.
12229///
12230/// Bounds a single TLS handshake so a client that opens TCP but never sends a
12231/// `ClientHello` cannot park the accept loop. 10s is generous for a real
12232/// handshake while still shedding a stalled connection promptly.
12233const fn default_tls_handshake_timeout_secs() -> u64 {
12234    10
12235}
12236
12237/// Default SSH user for `[deploy]`.
12238fn default_deploy_user() -> String {
12239    "root".to_owned()
12240}
12241
12242/// Default SSH port for `[deploy]`.
12243const fn default_deploy_ssh_port() -> u16 {
12244    22
12245}
12246
12247/// Default readiness window (seconds) before an `autumn deploy` rolls back.
12248const fn default_deploy_readiness_timeout_secs() -> u64 {
12249    60
12250}
12251
12252/// Default number of prior releases retained on the host for rollback.
12253const fn default_deploy_keep_releases() -> u32 {
12254    3
12255}
12256
12257/// Default profile the deployed app runs under. Defaults to the production
12258/// profile so an `autumn deploy` never silently boots under the `dev` profile.
12259fn default_deploy_profile() -> String {
12260    "prod".to_owned()
12261}
12262
12263/// Default directory for the ACME account key and issued certificates
12264/// (`[server.tls.acme] cache_dir`).
12265fn default_acme_cache_dir() -> PathBuf {
12266    PathBuf::from("config/acme")
12267}
12268
12269/// Default HTTP-01 challenge / redirect port (`[server.tls.acme]
12270/// http_challenge_port`). The ACME CA always validates HTTP-01 over port 80.
12271const fn default_acme_http_challenge_port() -> u16 {
12272    80
12273}
12274
12275/// Default renew-before window in days (`[server.tls.acme] renew_before_days`).
12276/// Let's Encrypt certificates are valid for 90 days; renewing with 30 days left
12277/// leaves ample slack for retries.
12278const fn default_acme_renew_before_days() -> u32 {
12279    30
12280}
12281
12282/// Default custom-domain registry directory.
12283fn default_custom_domains_dir() -> PathBuf {
12284    PathBuf::from("config/acme/domains")
12285}
12286
12287/// Default cap on registered tenant domains.
12288const fn default_custom_domains_max() -> usize {
12289    1000
12290}
12291
12292/// Default number of per-domain certificates held in memory.
12293const fn default_custom_domains_cert_cache() -> usize {
12294    256
12295}
12296
12297/// Default per-domain daily ACME order budget.
12298const fn default_custom_domains_per_domain_per_day() -> u32 {
12299    5
12300}
12301
12302/// Default deployment-wide hourly ACME order budget.
12303const fn default_custom_domains_global_per_hour() -> u32 {
12304    50
12305}
12306
12307/// Default first retry delay after a custom-domain failure.
12308const fn default_custom_domains_base_backoff() -> u64 {
12309    300
12310}
12311
12312/// Default cap on the custom-domain retry backoff.
12313const fn default_custom_domains_max_backoff() -> u64 {
12314    86_400
12315}
12316
12317/// Default custom-domain orchestrator poll interval.
12318const fn default_custom_domains_poll_secs() -> u64 {
12319    60
12320}
12321
12322/// Default credentials-store key holding the DNS provider credential
12323/// (`[server.tls.acme.dns] credential`).
12324fn default_acme_dns_credential() -> String {
12325    "acme_dns".to_owned()
12326}
12327
12328/// Default bound on the DNS-01 TXT propagation wait, in seconds
12329/// (`[server.tls.acme.dns] propagation_timeout_secs`). Five minutes covers the
12330/// slow tail of public-resolver caches without parking a renewal indefinitely.
12331const fn default_acme_dns_propagation_timeout_secs() -> u64 {
12332    300
12333}
12334
12335/// Default gap between propagation probes, in seconds
12336/// (`[server.tls.acme.dns] poll_interval_secs`).
12337const fn default_acme_dns_poll_interval_secs() -> u64 {
12338    5
12339}
12340
12341/// Upper bound on `[server.tls.acme.dns] propagation_timeout_secs`.
12342///
12343/// The wait is computed as `Instant::now() + Duration::from_secs(timeout)`, which
12344/// **panics** on overflow — inside the spawned renewal task, where the panic
12345/// silently kills the loop and leaves the self-signed placeholder served
12346/// forever. An hour is far past any real provider's propagation time.
12347const MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS: u64 = 3600;
12348
12349/// Default public resolvers the propagation wait queries
12350/// (`[server.tls.acme.dns] resolvers`). Two independent operators, so one
12351/// operator's stale cache cannot alone declare a record propagated.
12352fn default_acme_dns_resolvers() -> Vec<String> {
12353    vec!["1.1.1.1:53".to_owned(), "8.8.8.8:53".to_owned()]
12354}
12355
12356const fn default_health_enabled() -> bool {
12357    true
12358}
12359
12360fn default_health_path() -> String {
12361    "/health".to_owned()
12362}
12363
12364fn default_live_path() -> String {
12365    "/live".to_owned()
12366}
12367
12368const fn default_warn_on_pooler() -> bool {
12369    true
12370}
12371
12372const fn default_upgrade_enabled() -> bool {
12373    true
12374}
12375
12376const fn default_upgrade_ready_timeout() -> u64 {
12377    30
12378}
12379
12380impl Default for UpgradeConfig {
12381    fn default() -> Self {
12382        Self {
12383            enabled: default_upgrade_enabled(),
12384            ready_timeout_secs: default_upgrade_ready_timeout(),
12385        }
12386    }
12387}
12388
12389fn default_ready_path() -> String {
12390    "/ready".to_owned()
12391}
12392
12393fn default_startup_path() -> String {
12394    "/startup".to_owned()
12395}
12396
12397/// Seconds of drain window above the request timeout (issue #3058).
12398///
12399/// A request that starts just before shutdown can run for the full request
12400/// timeout. The drain window must be longer, or the watchdog stops it.
12401pub const DRAIN_MARGIN_SECS: u64 = 5;
12402
12403impl ServerConfig {
12404    /// A warning when `shutdown_timeout_secs` is shorter than the global
12405    /// request timeout plus [`DRAIN_MARGIN_SECS`]. `None` when the drain
12406    /// window is safe or no global request timeout is set. A per-route
12407    /// `timeout_ms` is not checked.
12408    #[must_use]
12409    pub fn drain_window_warning(&self) -> Option<String> {
12410        let timeout_ms = self.timeouts.request_timeout_ms.filter(|ms| *ms > 0)?;
12411        let safe_secs = timeout_ms.div_ceil(1_000).saturating_add(DRAIN_MARGIN_SECS);
12412        (self.shutdown_timeout_secs < safe_secs).then(|| {
12413            format!(
12414                "server.shutdown_timeout_secs ({}) is too short for \
12415                 server.timeouts.request_timeout_ms ({timeout_ms}): a request that starts \
12416                 just before shutdown can be stopped. Set shutdown_timeout_secs to {safe_secs} \
12417                 or more.",
12418                self.shutdown_timeout_secs
12419            )
12420        })
12421    }
12422}
12423
12424// ── Default trait impls ────────────────────────────────────────────
12425
12426impl Default for ServerConfig {
12427    fn default() -> Self {
12428        Self {
12429            port: default_port(),
12430            host: default_host(),
12431            strict_config: false,
12432            strict_config_enforce_all: false,
12433            shutdown_timeout_secs: default_shutdown_timeout(),
12434            prestop_grace_secs: default_prestop_grace(),
12435            upgrade: UpgradeConfig::default(),
12436            timeouts: RequestTimeoutsConfig::default(),
12437            http: HttpServerConfig::default(),
12438            unix_socket: None,
12439            max_concurrent_requests: None,
12440            capacity_contract: None,
12441            tls: None,
12442            admission: AdmissionConfig::default(),
12443        }
12444    }
12445}
12446
12447impl Default for DatabaseConfig {
12448    fn default() -> Self {
12449        Self {
12450            url: None,
12451            primary_url: None,
12452            replica_url: None,
12453            pool_size: default_pool_size(),
12454            primary_pool_size: None,
12455            replica_pool_size: None,
12456            replica_fallback: ReplicaFallback::default(),
12457            read_your_writes: ReadYourWrites::default(),
12458            pin_after_write_secs: default_pin_after_write_secs(),
12459            replica_max_lag_ms: None,
12460            warn_on_pooler: true,
12461            connect_timeout_secs: default_connect_timeout(),
12462            startup_wait_secs: 0,
12463            auto_migrate: None,
12464            auto_migrate_in_production: false,
12465            statement_timeout: None,
12466            idle_in_transaction_timeout: None,
12467            migration_lock_timeout: default_migration_lock_timeout(),
12468            migration_lock_retries: default_migration_lock_retries(),
12469            slow_query_threshold: default_slow_query_threshold(),
12470            shards: Vec::new(),
12471            directory_shard_router: false,
12472            max_connections_warn_threshold: default_max_connections_warn_threshold(),
12473            fleet: None,
12474        }
12475    }
12476}
12477
12478impl Default for LogConfig {
12479    fn default() -> Self {
12480        Self {
12481            level: default_log_level(),
12482            format: LogFormat::default(),
12483            filter_parameters: Vec::new(),
12484            unfilter_parameters: Vec::new(),
12485            access_log: default_access_log(),
12486            access_log_exclude: default_access_log_exclude(),
12487            capture: crate::log::capture::LogCaptureConfig::default(),
12488        }
12489    }
12490}
12491
12492impl Default for TelemetryConfig {
12493    fn default() -> Self {
12494        Self {
12495            enabled: false,
12496            service_name: default_telemetry_service_name(),
12497            service_namespace: None,
12498            service_version: default_telemetry_service_version(),
12499            environment: default_telemetry_environment(),
12500            otlp_endpoint: None,
12501            protocol: TelemetryProtocol::default(),
12502            strict: false,
12503            sample_ratio: default_telemetry_sample_ratio(),
12504        }
12505    }
12506}
12507
12508impl Default for HealthConfig {
12509    fn default() -> Self {
12510        Self {
12511            enabled: default_health_enabled(),
12512            path: default_health_path(),
12513            live_path: default_live_path(),
12514            ready_path: default_ready_path(),
12515            startup_path: default_startup_path(),
12516            detailed: false,
12517            cache_ttl_ms: default_health_cache_ttl_ms(),
12518            ping_timeout_ms: default_health_ping_timeout_ms(),
12519            db_readiness: default_health_db_readiness(),
12520            redis_readiness: false,
12521        }
12522    }
12523}
12524
12525// ----------------------------------------------------------------------------
12526// ConfigLoader — tier-1 boot-time replaceable config loading
12527// ----------------------------------------------------------------------------
12528
12529/// Pluggable boot-time configuration loader.
12530///
12531/// Replace the default TOML + env loader with a custom strategy (e.g. AWS
12532/// Secrets Manager, Consul, a JSON file, an HTTP fetch) by implementing this
12533/// trait and installing it on the [`AppBuilder`](crate::app::AppBuilder) via
12534/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
12535///
12536/// The trait's return type uses `impl Future + Send` so implementations can
12537/// freely use `async fn` in their bodies while the framework can still spawn
12538/// the load on any executor.
12539///
12540/// # Example
12541///
12542/// ```rust,no_run
12543/// use autumn_web::config::{AutumnConfig, ConfigError, ConfigLoader};
12544///
12545/// pub struct JsonFileConfigLoader { path: std::path::PathBuf }
12546///
12547/// impl ConfigLoader for JsonFileConfigLoader {
12548///     async fn load(&self) -> Result<AutumnConfig, ConfigError> {
12549///         let bytes = std::fs::read(&self.path).map_err(ConfigError::Io)?;
12550///         serde_json::from_slice(&bytes)
12551///             .map_err(|e| ConfigError::Validation(e.to_string()))
12552///     }
12553/// }
12554/// ```
12555pub trait ConfigLoader: Send + Sync + 'static {
12556    /// Load and return a fully-resolved [`AutumnConfig`].
12557    ///
12558    /// Implementations are responsible for any layering, profile resolution,
12559    /// and validation they care to apply. The default implementation
12560    /// ([`TomlEnvConfigLoader`]) preserves Autumn's five-layer load
12561    /// (framework defaults → profile defaults → `autumn.toml` →
12562    /// `autumn-{profile}.toml` → `AUTUMN_*` env vars).
12563    fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
12564}
12565
12566/// Default [`ConfigLoader`] — Autumn's five-layer TOML + env load strategy.
12567///
12568/// Delegates to [`AutumnConfig::load_with_env`] using [`OsEnv`] for environment
12569/// variable reads. This is the loader used when no override is installed via
12570/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
12571#[derive(Debug, Default, Clone)]
12572pub struct TomlEnvConfigLoader {
12573    /// Top-level config roots declared by plugins via
12574    /// [`AppBuilder::config_section`](crate::app::AppBuilder::config_section).
12575    /// Each is treated as known-and-opaque under `server.strict_config`. Empty
12576    /// by default, so a bare `TomlEnvConfigLoader::new()` behaves exactly as
12577    /// before the plugin config-section seam.
12578    allowed_plugin_roots: BTreeSet<String>,
12579}
12580
12581impl TomlEnvConfigLoader {
12582    /// Construct a new default loader with no declared plugin config roots.
12583    #[must_use]
12584    pub const fn new() -> Self {
12585        Self {
12586            allowed_plugin_roots: BTreeSet::new(),
12587        }
12588    }
12589
12590    /// Declare the plugin-owned top-level config roots this loader should treat
12591    /// as known-and-opaque under `server.strict_config`.
12592    ///
12593    /// Wired by [`AppBuilder::run`](crate::app::AppBuilder::run) from the roots
12594    /// registered through
12595    /// [`config_section`](crate::app::AppBuilder::config_section), so a
12596    /// plugin-enabled app boots under strict config while genuinely-unknown
12597    /// roots still hard-fail. See
12598    /// [`load_with_env_and_plugin_roots`](AutumnConfig::load_with_env_and_plugin_roots).
12599    #[must_use]
12600    pub fn with_plugin_config_roots(mut self, roots: BTreeSet<String>) -> Self {
12601        self.allowed_plugin_roots = roots;
12602        self
12603    }
12604}
12605
12606impl ConfigLoader for TomlEnvConfigLoader {
12607    async fn load(&self) -> Result<AutumnConfig, ConfigError> {
12608        // Feed a project-root `.env` into the `AUTUMN_*` env layer before
12609        // resolving config from the real environment. Rather than mutating the
12610        // process environment (unsound on a live multi-threaded runtime), `.env`
12611        // values are layered *under* the real environment via an overlay `Env`,
12612        // so a real env var always wins. The sync file IO in `resolve_dotenv_vars`
12613        // is fine on the async path. A malformed `.env` fails loudly here rather
12614        // than silently skipping developer-provided values.
12615        let base = OsEnv;
12616        let profile = resolve_profile(&base);
12617        // Resolve `.env` from the same base directory config uses for
12618        // `autumn.toml` (AUTUMN_MANIFEST_DIR when set, else the process CWD),
12619        // so a binary launched from outside its crate root reads the `.env`
12620        // next to its config instead of the process working directory.
12621        let dir = crate::dotenv::dotenv_base_dir(&base);
12622        let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
12623            .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
12624        let env = crate::dotenv::DotenvEnv::new(&base, vars);
12625        AutumnConfig::load_with_env_and_plugin_roots(&env, &self.allowed_plugin_roots)
12626    }
12627}
12628
12629const fn default_slow_query_threshold() -> std::time::Duration {
12630    std::time::Duration::from_millis(500)
12631}
12632
12633/// Parses a duration string like "500ms", "5s", "2m", "1h",
12634/// or a plain integer representing milliseconds.
12635///
12636/// # Errors
12637/// Returns a `String` describing the parse failure when the input is empty,
12638/// has an unrecognised suffix, or contains a non-numeric value.
12639pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
12640    if s.is_empty() {
12641        return Err("duration string is empty".to_owned());
12642    }
12643
12644    // Check if it's a plain integer
12645    if let Ok(ms) = s.parse::<u64>() {
12646        return Ok(std::time::Duration::from_millis(ms));
12647    }
12648
12649    // Try parsing suffix
12650    if let Some(val_str) = s.strip_suffix("ms") {
12651        let val = val_str
12652            .parse::<u64>()
12653            .map_err(|e| format!("invalid duration integer: {e}"))?;
12654        return Ok(std::time::Duration::from_millis(val));
12655    }
12656
12657    if let Some(val_str) = s.strip_suffix('s') {
12658        let val = val_str
12659            .parse::<u64>()
12660            .map_err(|e| format!("invalid duration integer: {e}"))?;
12661        return Ok(std::time::Duration::from_secs(val));
12662    }
12663
12664    if let Some(val_str) = s.strip_suffix('m') {
12665        let val = val_str
12666            .parse::<u64>()
12667            .map_err(|e| format!("invalid duration integer: {e}"))?;
12668        let secs = val.checked_mul(60).ok_or_else(|| {
12669            format!("duration overflow: '{s}' exceeds maximum representable value")
12670        })?;
12671        return Ok(std::time::Duration::from_secs(secs));
12672    }
12673
12674    if let Some(val_str) = s.strip_suffix('h') {
12675        let val = val_str
12676            .parse::<u64>()
12677            .map_err(|e| format!("invalid duration integer: {e}"))?;
12678        let secs = val.checked_mul(3600).ok_or_else(|| {
12679            format!("duration overflow: '{s}' exceeds maximum representable value")
12680        })?;
12681        return Ok(std::time::Duration::from_secs(secs));
12682    }
12683
12684    Err(format!("invalid duration format: '{s}'"))
12685}
12686
12687/// Deserialises a TOML/JSON value into a [`std::time::Duration`].
12688///
12689/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`) or a bare
12690/// integer (interpreted as milliseconds).
12691///
12692/// # Errors
12693/// Returns a deserialisation error if the value is not a valid duration.
12694pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
12695where
12696    D: serde::Deserializer<'de>,
12697{
12698    use serde::Deserialize;
12699
12700    #[derive(Deserialize)]
12701    #[serde(untagged)]
12702    enum DurationOrStr {
12703        String(String),
12704        Integer(u64),
12705    }
12706
12707    match DurationOrStr::deserialize(deserializer)? {
12708        DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
12709        DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
12710    }
12711}
12712
12713/// Deserialises an optional TOML/JSON value into <code>Option&lt;[std::time::Duration]&gt;</code>.
12714///
12715/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`), a bare
12716/// integer (milliseconds), or `null`/absent to mean no timeout.
12717///
12718/// # Errors
12719/// Returns a deserialisation error if the value is present but invalid.
12720pub fn deserialize_option_duration<'de, D>(
12721    deserializer: D,
12722) -> Result<Option<std::time::Duration>, D::Error>
12723where
12724    D: serde::Deserializer<'de>,
12725{
12726    use serde::Deserialize;
12727
12728    #[derive(Deserialize)]
12729    struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
12730
12731    Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
12732}
12733
12734/// Row-level multi-tenancy configuration.
12735#[derive(Debug, Clone, Deserialize)]
12736pub struct TenancyConfig {
12737    /// Whether row-level multi-tenancy is enabled.
12738    #[serde(default)]
12739    pub enabled: bool,
12740
12741    /// Source configuration from which the tenant ID is extracted.
12742    /// Values can be "header" (default), "subdomain", "session", "jwt".
12743    #[serde(default = "default_tenancy_source")]
12744    pub source: String,
12745
12746    /// Header name to lookup if source is "header". Default: "x-tenant-id".
12747    #[serde(default = "default_tenancy_header_name")]
12748    pub header_name: String,
12749
12750    /// Session key to lookup if source is "session". Default: "`tenant_id`".
12751    #[serde(default = "default_tenancy_session_key")]
12752    pub session_key: String,
12753
12754    /// JWT claim to lookup if source is "jwt". Default: "`tenant_id`".
12755    #[serde(default = "default_tenancy_jwt_claim")]
12756    pub jwt_claim: String,
12757
12758    /// JWT secret key used to verify the JWT signature.
12759    ///
12760    /// Stored as a [`secrecy::SecretString`] so the raw value is redacted
12761    /// from `Debug` output and zeroized on drop. Call
12762    /// [`secrecy::ExposeSecret::expose_secret`] at the point of use.
12763    #[serde(default)]
12764    pub jwt_secret: Option<secrecy::SecretString>,
12765
12766    /// Expected JWT issuer to validate.
12767    #[serde(default)]
12768    pub jwt_issuer: Option<String>,
12769
12770    /// Expected JWT audience (`aud` claim) to validate.
12771    /// When set, audience checking is enabled; when `None`, audience checking
12772    /// is skipped for backward compatibility.
12773    #[serde(default)]
12774    pub jwt_audience: Option<String>,
12775
12776    /// Optional base domain for subdomain tenancy.
12777    #[serde(default)]
12778    pub base_domain: Option<String>,
12779
12780    /// Request paths that bypass tenant resolution entirely, so they remain
12781    /// reachable without a tenant (e.g. `/login`, `/signup`, static assets).
12782    ///
12783    /// Matching is exact or slash-delimited prefix: `/login` matches `/login`
12784    /// and `/login/sso` but not `/login-admin`. The configured health check
12785    /// path is always treated as public regardless of this list.
12786    #[serde(default)]
12787    pub public_paths: Vec<String>,
12788
12789    /// Where to redirect when a non-public request has no valid tenant.
12790    ///
12791    /// When set, a missing/unauthenticated tenant on a protected path returns a
12792    /// 302 redirect here instead of a raw 401 — friendlier for browser `SaaS`
12793    /// logins. When `None`, the underlying authorization error is returned.
12794    #[serde(default)]
12795    pub login_redirect: Option<String>,
12796
12797    /// Soft per-tenant memory quota, in bytes, for in-process tenant cells.
12798    /// `0` disables the quota (unlimited).
12799    #[serde(default)]
12800    pub quota_bytes: usize,
12801
12802    /// Maximum number of resident tenant cells; least-recently-used cells are
12803    /// evicted above this. `0` = unbounded.
12804    #[serde(default)]
12805    pub max_cells: usize,
12806
12807    /// Evict a tenant cell whose last access exceeds this many seconds.
12808    /// `0` = disabled.
12809    #[serde(default)]
12810    pub idle_ttl_secs: u64,
12811
12812    /// The most requests of one tenant in flight at the same time (issue
12813    /// #3072). More get `503` with `Retry-After`. `0` (the default) sets no
12814    /// limit. Compare `server.max_concurrent_requests`, the limit for all
12815    /// tenants together.
12816    #[serde(default)]
12817    pub max_concurrent_requests: usize,
12818
12819    /// The most database connections that one tenant's requests hold at the
12820    /// same time (issue #3072). More get `503`. `0` (the default) sets no
12821    /// limit.
12822    #[serde(default)]
12823    pub max_db_connections: usize,
12824}
12825
12826fn default_tenancy_source() -> String {
12827    "header".to_string()
12828}
12829
12830fn default_tenancy_header_name() -> String {
12831    "x-tenant-id".to_string()
12832}
12833
12834fn default_tenancy_session_key() -> String {
12835    "tenant_id".to_string()
12836}
12837
12838fn default_tenancy_jwt_claim() -> String {
12839    "tenant_id".to_string()
12840}
12841
12842impl Default for TenancyConfig {
12843    fn default() -> Self {
12844        Self {
12845            enabled: false,
12846            source: default_tenancy_source(),
12847            header_name: default_tenancy_header_name(),
12848            session_key: default_tenancy_session_key(),
12849            jwt_claim: default_tenancy_jwt_claim(),
12850            jwt_secret: None,
12851            jwt_issuer: None,
12852            jwt_audience: None,
12853            base_domain: None,
12854            public_paths: Vec::new(),
12855            login_redirect: None,
12856            quota_bytes: 0,
12857            max_cells: 0,
12858            idle_ttl_secs: 0,
12859            max_concurrent_requests: 0,
12860            max_db_connections: 0,
12861        }
12862    }
12863}
12864
12865// ── Resilience configuration ───────────────────────────────────────────────
12866
12867/// Resilience policy configurations.
12868#[derive(Debug, Clone, Default, Deserialize)]
12869pub struct ResilienceConfig {
12870    /// Circuit breaker configurations.
12871    #[serde(default)]
12872    pub circuit_breaker: CircuitBreakerConfig,
12873}
12874
12875/// Circuit breaker configuration structure.
12876#[derive(Debug, Clone, Default, Deserialize)]
12877pub struct CircuitBreakerConfig {
12878    /// Default circuit breaker policies.
12879    #[serde(default)]
12880    pub defaults: CircuitBreakerPolicyConfig,
12881    /// Per-host circuit breaker policy overrides.
12882    #[serde(default)]
12883    pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
12884}
12885
12886/// Configurable settings for a circuit breaker policy.
12887#[derive(Debug, Clone, Default, Deserialize)]
12888pub struct CircuitBreakerPolicyConfig {
12889    /// Failure ratio threshold (e.g. 0.5) to trip the breaker.
12890    pub failure_ratio_threshold: Option<f64>,
12891    /// Sample window duration in seconds.
12892    pub sample_window_secs: Option<u64>,
12893    /// Minimum samples required to evaluate failure ratio.
12894    pub minimum_sample_count: Option<u64>,
12895    /// Open state duration in seconds before entering half-open.
12896    pub open_duration_secs: Option<u64>,
12897    /// Number of successful trials required in half-open state to close the breaker.
12898    pub half_open_trial_count: Option<u64>,
12899    /// A call of this many milliseconds or more is slow. `0` turns slow-call
12900    /// detection off.
12901    pub slow_call_duration_threshold_ms: Option<u64>,
12902    /// The breaker opens when the slow-call ratio is this value or more.
12903    /// Example: `0.8`.
12904    pub slow_call_rate_threshold: Option<f64>,
12905    /// What a call cancelled at or after the slow-call threshold counts as:
12906    /// `"slow"` or `"failure"`.
12907    pub cancelled_call_outcome: Option<crate::circuit_breaker::CancelledCallOutcome>,
12908}
12909
12910impl AutumnConfig {
12911    fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
12912        parse_env_option(
12913            env,
12914            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
12915            &mut self
12916                .resilience
12917                .circuit_breaker
12918                .defaults
12919                .failure_ratio_threshold,
12920        );
12921        parse_env_option(
12922            env,
12923            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
12924            &mut self.resilience.circuit_breaker.defaults.sample_window_secs,
12925        );
12926        parse_env_option(
12927            env,
12928            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
12929            &mut self
12930                .resilience
12931                .circuit_breaker
12932                .defaults
12933                .minimum_sample_count,
12934        );
12935        parse_env_option(
12936            env,
12937            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
12938            &mut self.resilience.circuit_breaker.defaults.open_duration_secs,
12939        );
12940        parse_env_option(
12941            env,
12942            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
12943            &mut self
12944                .resilience
12945                .circuit_breaker
12946                .defaults
12947                .half_open_trial_count,
12948        );
12949        let defaults = &mut self.resilience.circuit_breaker.defaults;
12950        parse_env_option(
12951            env,
12952            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
12953            &mut defaults.slow_call_duration_threshold_ms,
12954        );
12955        parse_env_option(
12956            env,
12957            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
12958            &mut defaults.slow_call_rate_threshold,
12959        );
12960        parse_env_option(
12961            env,
12962            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
12963            &mut defaults.cancelled_call_outcome,
12964        );
12965    }
12966}
12967
12968use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
12969use std::collections::{BTreeSet, HashMap, HashSet};
12970use std::sync::{Arc, Mutex};
12971
12972#[derive(Clone, Copy, PartialEq, Eq)]
12973enum AnyProbe {
12974    Str,
12975    Map,
12976    Seq,
12977}
12978
12979#[derive(Clone)]
12980pub struct SchemaDeserializer {
12981    path: Vec<String>,
12982    schema: Arc<Mutex<HashMap<String, HashSet<String>>>>,
12983    /// Per-path override for what `deserialize_any` feeds. Absent = `Str`.
12984    /// A path is escalated (Str→Map→Seq) across walk passes when its visitor
12985    /// rejects the current probe (e.g. `jobs.queues`'s seq/map-only visitor
12986    /// rejects the scalar `"0"`). See `get_schema_keys`.
12987    any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
12988    /// Paths whose `deserialize_any` probe was rejected during the current pass.
12989    rejected: Arc<Mutex<Vec<String>>>,
12990}
12991
12992impl Default for SchemaDeserializer {
12993    fn default() -> Self {
12994        Self::new()
12995    }
12996}
12997
12998impl SchemaDeserializer {
12999    #[must_use]
13000    pub fn new() -> Self {
13001        Self {
13002            path: Vec::new(),
13003            schema: Arc::new(Mutex::new(HashMap::new())),
13004            any_probe: Arc::new(Mutex::new(HashMap::new())),
13005            rejected: Arc::new(Mutex::new(Vec::new())),
13006        }
13007    }
13008
13009    #[must_use]
13010    pub fn into_schema(self) -> HashMap<String, HashSet<String>> {
13011        let lock = self
13012            .schema
13013            .lock()
13014            .unwrap_or_else(std::sync::PoisonError::into_inner);
13015        lock.clone()
13016    }
13017}
13018
13019impl<'de> de::Deserializer<'de> for SchemaDeserializer {
13020    type Error = serde::de::value::Error;
13021
13022    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13023    where
13024        V: Visitor<'de>,
13025    {
13026        // `deserialize_any` is inherently ambiguous for a placeholder walker:
13027        // untagged SCALAR parsers (e.g. `deserialize_duration`) need a string,
13028        // while a visitor that accepts only seq/map (e.g. `JobQueuesConfig` at
13029        // `jobs.queues`) rejects a string and aborts the whole remaining walk
13030        // (#1890). We can't know which shape a given visitor wants, and serde
13031        // seeds can't be retried mid-walk, so we probe with a scalar by default
13032        // and let `get_schema_keys` re-run the walk, escalating any REJECTED
13033        // path to a map/seq probe on the next pass until none reject.
13034        let path = self.path.join(".");
13035        let probe = self
13036            .any_probe
13037            .lock()
13038            .unwrap_or_else(std::sync::PoisonError::into_inner)
13039            .get(&path)
13040            .copied()
13041            .unwrap_or(AnyProbe::Str);
13042        let result = match probe {
13043            // "0" is a valid non-empty string that also parses as an int/duration,
13044            // so untagged string- and number-shaped scalar parsers both accept it.
13045            AnyProbe::Str => visitor.visit_str("0"),
13046            // Empty map/seq: a seq/map-only visitor accepts it and yields an empty
13047            // value, so the walk records the field as a leaf and CONTINUES past it
13048            // (we intentionally do NOT descend — e.g. jobs.queues has dynamic keys).
13049            AnyProbe::Map => visitor.visit_map(SchemaMapAccess {
13050                fields: [].iter(),
13051                current_field: None,
13052                deserializer: self.clone(),
13053            }),
13054            AnyProbe::Seq => visitor.visit_seq(SchemaSeqAccess {
13055                done: true,
13056                deserializer: self.clone(),
13057            }),
13058        };
13059        if result.is_err() {
13060            self.rejected
13061                .lock()
13062                .unwrap_or_else(std::sync::PoisonError::into_inner)
13063                .push(path);
13064        }
13065        result
13066    }
13067
13068    fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13069    where
13070        V: Visitor<'de>,
13071    {
13072        visitor.visit_bool(false)
13073    }
13074
13075    fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13076    where
13077        V: Visitor<'de>,
13078    {
13079        visitor.visit_i8(0)
13080    }
13081
13082    fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13083    where
13084        V: Visitor<'de>,
13085    {
13086        visitor.visit_i16(0)
13087    }
13088
13089    fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13090    where
13091        V: Visitor<'de>,
13092    {
13093        visitor.visit_i32(0)
13094    }
13095
13096    fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13097    where
13098        V: Visitor<'de>,
13099    {
13100        visitor.visit_i64(0)
13101    }
13102
13103    fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13104    where
13105        V: Visitor<'de>,
13106    {
13107        visitor.visit_u8(0)
13108    }
13109
13110    fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13111    where
13112        V: Visitor<'de>,
13113    {
13114        visitor.visit_u16(0)
13115    }
13116
13117    fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13118    where
13119        V: Visitor<'de>,
13120    {
13121        visitor.visit_u32(0)
13122    }
13123
13124    fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13125    where
13126        V: Visitor<'de>,
13127    {
13128        visitor.visit_u64(0)
13129    }
13130
13131    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13132    where
13133        V: Visitor<'de>,
13134    {
13135        visitor.visit_f32(0.0)
13136    }
13137
13138    fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13139    where
13140        V: Visitor<'de>,
13141    {
13142        visitor.visit_f64(0.0)
13143    }
13144
13145    fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13146    where
13147        V: Visitor<'de>,
13148    {
13149        visitor.visit_char('\0')
13150    }
13151
13152    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13153    where
13154        V: Visitor<'de>,
13155    {
13156        visitor.visit_str("")
13157    }
13158
13159    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13160    where
13161        V: Visitor<'de>,
13162    {
13163        visitor.visit_string(String::new())
13164    }
13165
13166    fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13167    where
13168        V: Visitor<'de>,
13169    {
13170        visitor.visit_bytes(&[])
13171    }
13172
13173    fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13174    where
13175        V: Visitor<'de>,
13176    {
13177        visitor.visit_byte_buf(Vec::new())
13178    }
13179
13180    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13181    where
13182        V: Visitor<'de>,
13183    {
13184        visitor.visit_some(self)
13185    }
13186
13187    fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13188    where
13189        V: Visitor<'de>,
13190    {
13191        visitor.visit_unit()
13192    }
13193
13194    fn deserialize_unit_struct<V>(
13195        self,
13196        _name: &'static str,
13197        visitor: V,
13198    ) -> Result<V::Value, Self::Error>
13199    where
13200        V: Visitor<'de>,
13201    {
13202        visitor.visit_unit()
13203    }
13204
13205    fn deserialize_newtype_struct<V>(
13206        self,
13207        _name: &'static str,
13208        visitor: V,
13209    ) -> Result<V::Value, Self::Error>
13210    where
13211        V: Visitor<'de>,
13212    {
13213        visitor.visit_newtype_struct(self)
13214    }
13215
13216    fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13217    where
13218        V: Visitor<'de>,
13219    {
13220        visitor.visit_seq(SchemaSeqAccess {
13221            done: false,
13222            deserializer: self,
13223        })
13224    }
13225
13226    fn deserialize_tuple<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
13227    where
13228        V: Visitor<'de>,
13229    {
13230        self.deserialize_seq(visitor)
13231    }
13232
13233    fn deserialize_tuple_struct<V>(
13234        self,
13235        _name: &'static str,
13236        _len: usize,
13237        visitor: V,
13238    ) -> Result<V::Value, Self::Error>
13239    where
13240        V: Visitor<'de>,
13241    {
13242        self.deserialize_seq(visitor)
13243    }
13244
13245    fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13246    where
13247        V: Visitor<'de>,
13248    {
13249        visitor.visit_map(SchemaMapAccess {
13250            fields: [].iter(),
13251            current_field: None,
13252            deserializer: self,
13253        })
13254    }
13255
13256    fn deserialize_struct<V>(
13257        self,
13258        _name: &'static str,
13259        fields: &'static [&'static str],
13260        visitor: V,
13261    ) -> Result<V::Value, Self::Error>
13262    where
13263        V: Visitor<'de>,
13264    {
13265        let path_str = self.path.join(".");
13266        {
13267            let mut schema = self.schema.lock().unwrap();
13268            schema.insert(path_str, fields.iter().map(|&s| s.to_string()).collect());
13269        }
13270
13271        visitor.visit_map(SchemaMapAccess {
13272            fields: fields.iter(),
13273            current_field: None,
13274            deserializer: self,
13275        })
13276    }
13277
13278    fn deserialize_enum<V>(
13279        self,
13280        _name: &'static str,
13281        variants: &'static [&'static str],
13282        visitor: V,
13283    ) -> Result<V::Value, Self::Error>
13284    where
13285        V: Visitor<'de>,
13286    {
13287        // Feed the FIRST declared variant name (not `""`) so serde's derived
13288        // variant-identifier visitor accepts it. An empty tag is an "unknown
13289        // variant" error that aborts the ENTIRE remaining schema traversal, so a
13290        // single enum field (e.g. `server.tls.acme.directory`) would drop every
13291        // sibling/subsequent section (`database`, …) from the derived schema —
13292        // silently disabling the strict unknown-key validator for them. The enum
13293        // is still treated as an opaque leaf: every `SchemaEnumAccess` variant
13294        // arm resolves to `visit_unit` without recursing.
13295        visitor.visit_enum(SchemaEnumAccess {
13296            variant: variants.first().copied().unwrap_or_default(),
13297        })
13298    }
13299
13300    fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13301    where
13302        V: Visitor<'de>,
13303    {
13304        visitor.visit_str("")
13305    }
13306
13307    fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13308    where
13309        V: Visitor<'de>,
13310    {
13311        visitor.visit_unit()
13312    }
13313}
13314
13315struct SchemaSeqAccess {
13316    done: bool,
13317    deserializer: SchemaDeserializer,
13318}
13319
13320impl<'de> SeqAccess<'de> for SchemaSeqAccess {
13321    type Error = serde::de::value::Error;
13322
13323    fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
13324    where
13325        T: DeserializeSeed<'de>,
13326    {
13327        if self.done {
13328            Ok(None)
13329        } else {
13330            self.done = true;
13331            seed.deserialize(self.deserializer.clone()).map(Some)
13332        }
13333    }
13334}
13335
13336struct SchemaMapAccess {
13337    fields: std::slice::Iter<'static, &'static str>,
13338    current_field: Option<&'static str>,
13339    deserializer: SchemaDeserializer,
13340}
13341
13342impl<'de> MapAccess<'de> for SchemaMapAccess {
13343    type Error = serde::de::value::Error;
13344
13345    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
13346    where
13347        K: DeserializeSeed<'de>,
13348    {
13349        if let Some(&field) = self.fields.next() {
13350            self.current_field = Some(field);
13351            seed.deserialize(de::value::StrDeserializer::new(field))
13352                .map(Some)
13353        } else {
13354            Ok(None)
13355        }
13356    }
13357
13358    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
13359    where
13360        V: DeserializeSeed<'de>,
13361    {
13362        let field = self.current_field.take().unwrap();
13363        let mut new_path = self.deserializer.path.clone();
13364        new_path.push(field.to_string());
13365
13366        let nested = SchemaDeserializer {
13367            path: new_path,
13368            schema: self.deserializer.schema.clone(),
13369            any_probe: self.deserializer.any_probe.clone(),
13370            rejected: self.deserializer.rejected.clone(),
13371        };
13372        seed.deserialize(nested)
13373    }
13374}
13375
13376struct SchemaEnumAccess {
13377    /// The variant name to report to serde's derived variant-identifier visitor.
13378    /// Must be a REAL variant name (the first declared one), never `""`, or
13379    /// serde returns an "unknown variant" error that aborts schema traversal.
13380    variant: &'static str,
13381}
13382
13383impl<'de> de::EnumAccess<'de> for SchemaEnumAccess {
13384    type Error = serde::de::value::Error;
13385    type Variant = Self;
13386
13387    fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
13388    where
13389        V: de::DeserializeSeed<'de>,
13390    {
13391        let val = seed.deserialize(de::value::StrDeserializer::new(self.variant))?;
13392        Ok((val, self))
13393    }
13394}
13395
13396impl<'de> de::VariantAccess<'de> for SchemaEnumAccess {
13397    type Error = serde::de::value::Error;
13398
13399    fn unit_variant(self) -> Result<(), Self::Error> {
13400        Ok(())
13401    }
13402
13403    fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
13404    where
13405        T: de::DeserializeSeed<'de>,
13406    {
13407        seed.deserialize(SchemaDeserializer::new())
13408    }
13409
13410    fn tuple_variant<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
13411    where
13412        V: Visitor<'de>,
13413    {
13414        visitor.visit_unit()
13415    }
13416
13417    fn struct_variant<V>(
13418        self,
13419        _fields: &'static [&'static str],
13420        visitor: V,
13421    ) -> Result<V::Value, Self::Error>
13422    where
13423        V: Visitor<'de>,
13424    {
13425        visitor.visit_unit()
13426    }
13427}
13428
13429#[cfg(test)]
13430mod tests {
13431
13432    use super::*;
13433
13434    struct FakeEnv(std::collections::HashMap<String, String>);
13435    impl Env for FakeEnv {
13436        fn var(&self, key: &str) -> Result<String, std::env::VarError> {
13437            self.0
13438                .get(key)
13439                .cloned()
13440                .ok_or(std::env::VarError::NotPresent)
13441        }
13442    }
13443
13444    #[test]
13445    fn test_schema_extractor() {
13446        let keys = AutumnConfig::get_schema_keys();
13447        assert!(keys.contains_key(""));
13448        let root_keys = &keys[""];
13449        assert!(root_keys.contains("server"));
13450        assert!(root_keys.contains("database"));
13451
13452        assert!(keys.contains_key("server"));
13453        assert!(keys["server"].contains("port"));
13454        assert!(keys["server"].contains("host"));
13455
13456        assert!(keys.contains_key("database"));
13457        assert!(keys["database"].contains("primary_url"));
13458    }
13459
13460    // Regression (#1608): `server.tls.acme.directory` is the `AcmeDirectory`
13461    // enum, declared under `server` — which precedes `database` in `AutumnConfig`.
13462    // The `SchemaDeserializer` must treat that enum as an opaque leaf and keep
13463    // walking; if it instead errors on the variant tag it aborts the whole
13464    // traversal at the enum, dropping `database` (and every later section) from
13465    // the derived schema. That silently disables the strict unknown-key validator
13466    // for `[database]`, so a typo like `primry_url` stops being flagged.
13467    #[cfg(feature = "acme")]
13468    #[test]
13469    fn acme_enum_field_does_not_truncate_schema_traversal() {
13470        let keys = AutumnConfig::get_schema_keys();
13471        assert!(
13472            keys.contains_key("server.tls.acme"),
13473            "acme section must be in the schema"
13474        );
13475        assert!(
13476            keys.contains_key("database"),
13477            "database schema dropped: the acme enum truncated traversal"
13478        );
13479        assert!(keys["database"].contains("primary_url"));
13480
13481        // The unknown-key validator must still flag a typo in a section declared
13482        // after the enum, with the edit-distance suggestion.
13483        let errs = AutumnConfig::validate_toml("[database]\nprimry_url = \"x\"\n", &keys);
13484        assert_eq!(
13485            errs,
13486            vec![(
13487                "database.primry_url".to_owned(),
13488                Some("database.primary_url".to_owned())
13489            )]
13490        );
13491    }
13492
13493    #[test]
13494    fn test_strict_config_startup_fails_on_typo() {
13495        let temp = tempfile::tempdir().unwrap();
13496        let config_path = temp.path().join("autumn.toml");
13497        std::fs::write(
13498            &config_path,
13499            "[database]\nprimry_url = \"postgres://localhost/db\"",
13500        )
13501        .unwrap();
13502
13503        let env = FakeEnv(
13504            [
13505                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13506                (
13507                    "AUTUMN_MANIFEST_DIR".to_owned(),
13508                    temp.path().to_str().unwrap().to_owned(),
13509                ),
13510            ]
13511            .into(),
13512        );
13513
13514        let res = AutumnConfig::load_with_env(&env);
13515        assert!(res.is_err());
13516        let err_str = format!("{:?}", res.err().unwrap());
13517        assert!(err_str.contains("primry_url"));
13518    }
13519
13520    // #2063 helper: a `prod`, manifest-scoped env with `strict_config` sourced
13521    // from the on-disk `autumn.toml`. `prod` is pinned (not `dev`) so the
13522    // dev-only injected `[storage]` smart-default can't masquerade as an unknown
13523    // top-level root and skew these assertions — same reason the #1890 tests do.
13524    fn strict_prod_env_2063(temp: &std::path::Path) -> FakeEnv {
13525        FakeEnv(
13526            [
13527                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13528                (
13529                    "AUTUMN_MANIFEST_DIR".to_owned(),
13530                    temp.to_str().unwrap().to_owned(),
13531                ),
13532            ]
13533            .into(),
13534        )
13535    }
13536
13537    // #2063: the deploy CLI's lenient-unknown-roots load accepts a plugin-owned
13538    // top-level config table (`[media]`) under `strict_config` — the CLI cannot
13539    // know the app's plugin set — while the STRICT (app-boot) load still rejects
13540    // it, so app boot remains the authoritative strict gate for plugin roots.
13541    #[test]
13542    fn deploy_cli_lenient_accepts_plugin_owned_top_level_root() {
13543        let temp = tempfile::tempdir().unwrap();
13544        std::fs::write(
13545            temp.path().join("autumn.toml"),
13546            "[server]\nstrict_config = true\n\n[media]\nmediamtx_host = \"cdn.example\"\n",
13547        )
13548        .unwrap();
13549        let env = strict_prod_env_2063(temp.path());
13550
13551        // App boot stays strict: an unknown `[media]` root is a hard error.
13552        let strict = AutumnConfig::load_with_env(&env);
13553        assert!(
13554            strict.is_err(),
13555            "app boot must stay strict for unknown plugin roots: {strict:?}"
13556        );
13557        let strict_err = format!("{:?}", strict.err().unwrap());
13558        assert!(
13559            strict_err.contains("media"),
13560            "strict error should name the unknown root: {strict_err}"
13561        );
13562
13563        // Deploy CLI accepts it as opaque (warn, not fail) so the project deploys.
13564        let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13565        assert!(
13566            lenient.is_ok(),
13567            "deploy CLI must accept plugin-owned [media] under strict_config: {lenient:?}"
13568        );
13569    }
13570
13571    // #2063: any genuinely-unknown top-level root (not just `[media]`) is
13572    // warn-not-fail under the lenient CLI load, and still fatal under app boot.
13573    #[test]
13574    fn deploy_cli_lenient_accepts_arbitrary_unknown_top_level_root() {
13575        let temp = tempfile::tempdir().unwrap();
13576        std::fs::write(
13577            temp.path().join("autumn.toml"),
13578            "[server]\nstrict_config = true\n\n[definitely_not_a_root]\nx = 1\n",
13579        )
13580        .unwrap();
13581        let env = strict_prod_env_2063(temp.path());
13582
13583        assert!(
13584            AutumnConfig::load_with_env(&env).is_err(),
13585            "app boot must reject an unknown top-level root"
13586        );
13587        assert!(
13588            AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_ok(),
13589            "deploy CLI must accept an unknown top-level root as opaque"
13590        );
13591    }
13592
13593    // #2063: leniency is scoped to top-level ROOTS only. A typo INSIDE a known
13594    // section (`[database] primry_url`) stays a hard error even under the lenient
13595    // CLI load — the CLI does not soften validation of sections it knows.
13596    #[test]
13597    fn deploy_cli_lenient_still_rejects_known_section_typo() {
13598        let temp = tempfile::tempdir().unwrap();
13599        std::fs::write(
13600            temp.path().join("autumn.toml"),
13601            "[server]\nstrict_config = true\n\n[database]\nprimry_url = \"postgres://localhost/db\"\n",
13602        )
13603        .unwrap();
13604        let env = strict_prod_env_2063(temp.path());
13605
13606        let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13607        assert!(
13608            res.is_err(),
13609            "known-section typo must still hard-fail under the lenient CLI load: {res:?}"
13610        );
13611        let err = format!("{:?}", res.err().unwrap());
13612        assert!(
13613            err.contains("primry_url"),
13614            "error should name the known-section typo: {err}"
13615        );
13616    }
13617
13618    // #2063: malformed TOML is fatal everywhere — the lenient policy never
13619    // softens a parse failure.
13620    #[test]
13621    fn deploy_cli_lenient_still_rejects_malformed_toml() {
13622        let temp = tempfile::tempdir().unwrap();
13623        std::fs::write(
13624            temp.path().join("autumn.toml"),
13625            "[server]\nstrict_config = true\n\nthis is not = = valid toml\n",
13626        )
13627        .unwrap();
13628        let env = strict_prod_env_2063(temp.path());
13629
13630        assert!(
13631            AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_err(),
13632            "malformed TOML must still fail under the lenient CLI load"
13633        );
13634    }
13635
13636    // #2067: the lenient deploy-CLI load must NOT soften a PROFILE-PREFIXED
13637    // unknown root like `[profile.prod.media]`. Its schema parent is empty
13638    // (the profile prefix is stripped before root-schema validation), but its
13639    // actual path (`profile.prod.media`) is not a true top-level root — so it
13640    // stays strict and hard-fails, exactly as the deployed app rejects it at
13641    // boot (the `config_section` seam exempts ONLY the true top-level `[media]`
13642    // via `path.is_empty()`). Otherwise deploy would pass while remote boot
13643    // fails.
13644    #[test]
13645    fn deploy_cli_lenient_still_rejects_profile_prefixed_root() {
13646        let temp = tempfile::tempdir().unwrap();
13647        std::fs::write(
13648            temp.path().join("autumn.toml"),
13649            "[server]\nstrict_config = true\n\n[profile.prod.media]\nmediamtx_host = \"cdn.example\"\n",
13650        )
13651        .unwrap();
13652        let env = strict_prod_env_2063(temp.path());
13653
13654        let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13655        assert!(
13656            res.is_err(),
13657            "a profile-prefixed root ([profile.prod.media]) must stay strict under \
13658             the lenient CLI load — it is not a true top-level root and the deployed \
13659             app rejects it at boot: {res:?}"
13660        );
13661        let err = format!("{:?}", res.err().unwrap());
13662        assert!(
13663            err.contains("media"),
13664            "error should name the profile-prefixed root: {err}"
13665        );
13666    }
13667
13668    // #2067: the profile-prefix strictness is not media-specific — a
13669    // profile-prefixed genuinely-unknown NON-plugin root
13670    // (`[profile.prod.definitely_unknown]`) also stays a hard error under the
13671    // lenient CLI load; only TRUE top-level roots are ever softened.
13672    #[test]
13673    fn deploy_cli_lenient_still_rejects_profile_prefixed_unknown_root() {
13674        let temp = tempfile::tempdir().unwrap();
13675        std::fs::write(
13676            temp.path().join("autumn.toml"),
13677            "[server]\nstrict_config = true\n\n[profile.prod.definitely_unknown]\nx = 1\n",
13678        )
13679        .unwrap();
13680        let env = strict_prod_env_2063(temp.path());
13681
13682        let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13683        assert!(
13684            res.is_err(),
13685            "a profile-prefixed unknown root must stay strict under the lenient CLI \
13686             load: {res:?}"
13687        );
13688        let err = format!("{:?}", res.err().unwrap());
13689        assert!(
13690            err.contains("definitely_unknown"),
13691            "error should name the profile-prefixed unknown root: {err}"
13692        );
13693    }
13694
13695    // #2067: the lenient deploy-CLI demotion applies ONLY to a true top-level
13696    // root whose TOML value is a TABLE. A registered/unknown root written as a
13697    // SCALAR (`media = "enabled"`) or an ARRAY (`media = ["a", "b"]`) is a
13698    // malformed section nothing would deserialize, so it must HARD-FAIL under
13699    // the lenient CLI load too — exactly as the deployed app rejects it at boot
13700    // (the #2061 `config_section` seam exempts a plugin root only when
13701    // `val.is_table()`). Without the `is_table` gate deploy would accept a
13702    // non-table root that app boot rejects.
13703    #[test]
13704    fn deploy_cli_lenient_still_rejects_non_table_root() {
13705        // Scalar root: `media = "enabled"`.
13706        let temp = tempfile::tempdir().unwrap();
13707        std::fs::write(
13708            temp.path().join("autumn.toml"),
13709            "[server]\nstrict_config = true\n\nmedia = \"enabled\"\n",
13710        )
13711        .unwrap();
13712        let env = strict_prod_env_2063(temp.path());
13713
13714        let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13715        assert!(
13716            res.is_err(),
13717            "a SCALAR top-level root (media = \"enabled\") must hard-fail under the \
13718             lenient CLI load — it is not a table and the deployed app rejects it at \
13719             boot: {res:?}"
13720        );
13721        let err = format!("{:?}", res.err().unwrap());
13722        assert!(
13723            err.contains("media"),
13724            "error should name the non-table root: {err}"
13725        );
13726
13727        // Array root: `media = ["a", "b"]`.
13728        let temp2 = tempfile::tempdir().unwrap();
13729        std::fs::write(
13730            temp2.path().join("autumn.toml"),
13731            "[server]\nstrict_config = true\n\nmedia = [\"a\", \"b\"]\n",
13732        )
13733        .unwrap();
13734        let env2 = strict_prod_env_2063(temp2.path());
13735
13736        let res2 = AutumnConfig::load_with_env_lenient_unknown_roots(&env2);
13737        assert!(
13738            res2.is_err(),
13739            "an ARRAY top-level root (media = [\"a\", \"b\"]) must hard-fail under the \
13740             lenient CLI load — it is not a table and the deployed app rejects it at \
13741             boot: {res2:?}"
13742        );
13743        let err2 = format!("{:?}", res2.err().unwrap());
13744        assert!(
13745            err2.contains("media"),
13746            "error should name the non-table root: {err2}"
13747        );
13748    }
13749
13750    // #2067: a legitimately quoted-dotted top-level table root — the valid TOML form of
13751    // a plugin `config_section("my.plugin")`, whose top-level table is `["my.plugin"]`,
13752    // one quoted key that happens to contain a dot — must be leniently accepted by the
13753    // deploy CLI, because app boot accepts it too: the #2061 exemption keys on the raw
13754    // table key with `path.is_empty()`. The earlier `!path.contains('.')` heuristic
13755    // wrongly hard-failed it, since the rendered `my.plugin` is ambiguous between a
13756    // quoted top-level key and a two-level path. Gating on the structural
13757    // `is_top_level`, an empty parent path, fixes it.
13758    #[test]
13759    fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
13760        // `["my.plugin"]` is a quoted top-level key CONTAINING a dot (one
13761        // structural top-level table), NOT the nested `[my.plugin]` two-level
13762        // form — this is exactly what `config_section("my.plugin")` produces.
13763        let temp = tempfile::tempdir().unwrap();
13764        std::fs::write(
13765            temp.path().join("autumn.toml"),
13766            "[server]\nstrict_config = true\n\n[\"my.plugin\"]\nenabled = true\n",
13767        )
13768        .unwrap();
13769        let env = strict_prod_env_2063(temp.path());
13770
13771        let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13772        assert!(
13773            lenient.is_ok(),
13774            "deploy CLI must leniently accept a quoted-dotted TOP-LEVEL table root \
13775             ([\"my.plugin\"]) — it is a true top-level plugin root the app accepts at \
13776             boot, and top-level-ness is structural (empty parent path), not \
13777             `path.contains('.')`: {lenient:?}"
13778        );
13779
13780        // Inline-table form of the same quoted-dotted top-level root is
13781        // equivalent. It is written BEFORE the `[server]` header so it binds at
13782        // the document top level, not inside `[server]`.
13783        let temp2 = tempfile::tempdir().unwrap();
13784        std::fs::write(
13785            temp2.path().join("autumn.toml"),
13786            "\"my.plugin\" = { enabled = true }\n\n[server]\nstrict_config = true\n",
13787        )
13788        .unwrap();
13789        let env2 = strict_prod_env_2063(temp2.path());
13790        assert!(
13791            AutumnConfig::load_with_env_lenient_unknown_roots(&env2).is_ok(),
13792            "deploy CLI must accept the inline-table quoted-dotted top-level root too"
13793        );
13794    }
13795
13796    // 7a (#1890): a typo in a section that ONLY became strictly validated by the
13797    // schema-walk fix (here `[log]`, declared after `database`) must WARN, not
13798    // fail, during the one-release warn-first rollout.
13799    #[test]
13800    fn post_database_section_typo_warns_but_does_not_fail() {
13801        let temp = tempfile::tempdir().unwrap();
13802        let config_path = temp.path().join("autumn.toml");
13803        std::fs::write(&config_path, "[log]\nbogus_zzz = true\n").unwrap();
13804
13805        let env = FakeEnv(
13806            [
13807                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13808                // Pin a non-dev profile: the `dev` smart-defaults inject a
13809                // feature-gated `[storage]` table which, with the `storage`
13810                // feature off, is flagged as a hard top-level unknown key and
13811                // would derail this test regardless of the `[log]` typo.
13812                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13813                (
13814                    "AUTUMN_MANIFEST_DIR".to_owned(),
13815                    temp.path().to_str().unwrap().to_owned(),
13816                ),
13817            ]
13818            .into(),
13819        );
13820
13821        let res = AutumnConfig::load_with_env(&env);
13822        assert!(
13823            res.is_ok(),
13824            "a post-database section typo must warn (not fail) under warn-first rollout: {res:?}"
13825        );
13826    }
13827
13828    // 7b (#1890): with `strict_config_enforce_all` set, the SAME post-database
13829    // typo is promoted to a hard error.
13830    #[test]
13831    fn post_database_section_typo_fails_under_enforce_all() {
13832        let temp = tempfile::tempdir().unwrap();
13833        let config_path = temp.path().join("autumn.toml");
13834        std::fs::write(
13835            &config_path,
13836            "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n[log]\nbogus_zzz = true\n",
13837        )
13838        .unwrap();
13839
13840        let env = FakeEnv(
13841            [
13842                // Non-dev profile so the `dev` smart-defaults' feature-gated
13843                // `[storage]` table isn't injected — otherwise (storage feature
13844                // off) the test would fail on `storage`, not the `[log]` typo it
13845                // is meant to exercise.
13846                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13847                (
13848                    "AUTUMN_MANIFEST_DIR".to_owned(),
13849                    temp.path().to_str().unwrap().to_owned(),
13850                ),
13851            ]
13852            .into(),
13853        );
13854
13855        let res = AutumnConfig::load_with_env(&env);
13856        assert!(
13857            res.is_err(),
13858            "strict_config_enforce_all must hard-fail the post-database typo"
13859        );
13860        let err_str = format!("{:?}", res.err().unwrap());
13861        assert!(
13862            err_str.contains("bogus_zzz"),
13863            "error should name the key: {err_str}"
13864        );
13865    }
13866
13867    // 7c (#1890 regression guard): sections that were strictly validated BEFORE
13868    // the fix (here `[server]`) must keep hard-failing on unknown keys.
13869    #[test]
13870    fn pre_database_section_typo_still_hard_fails() {
13871        let temp = tempfile::tempdir().unwrap();
13872        let config_path = temp.path().join("autumn.toml");
13873        std::fs::write(&config_path, "[server]\nbogus_zzz = true\n").unwrap();
13874
13875        let env = FakeEnv(
13876            [
13877                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13878                // Non-dev profile so the `dev` smart-defaults' feature-gated
13879                // `[storage]` table isn't injected; this test must fail on the
13880                // `[server]` typo, not on `storage` (storage feature off).
13881                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13882                (
13883                    "AUTUMN_MANIFEST_DIR".to_owned(),
13884                    temp.path().to_str().unwrap().to_owned(),
13885                ),
13886            ]
13887            .into(),
13888        );
13889
13890        let res = AutumnConfig::load_with_env(&env);
13891        assert!(
13892            res.is_err(),
13893            "an unknown [server] key must still hard-fail (pre-fix strictness preserved)"
13894        );
13895        let err_str = format!("{:?}", res.err().unwrap());
13896        assert!(
13897            err_str.contains("bogus_zzz"),
13898            "error should name the key: {err_str}"
13899        );
13900    }
13901
13902    // ── Plugin config-section seam (#1974 item 7) ─────────────────────────────
13903    //
13904    // A plugin owns a top-level `[media]` table core's closed schema knows
13905    // nothing about. `load_with_env_and_plugin_roots` exempts declared roots
13906    // from the strict unknown-key check as known-and-opaque, while every other
13907    // unknown root still hard-fails. All tests pin `AUTUMN_ENV=prod` so the dev
13908    // smart-defaults' feature-gated `[storage]` root isn't injected (storage
13909    // feature off), which would otherwise be flagged independently of `[media]`.
13910
13911    fn plugin_roots(names: &[&str]) -> BTreeSet<String> {
13912        names.iter().map(|s| (*s).to_owned()).collect()
13913    }
13914
13915    fn strict_prod_env(dir: &std::path::Path, enforce_all: bool) -> FakeEnv {
13916        let mut vars = vec![
13917            ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13918            ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13919            (
13920                "AUTUMN_MANIFEST_DIR".to_owned(),
13921                dir.to_str().unwrap().to_owned(),
13922            ),
13923        ];
13924        if enforce_all {
13925            vars.push((
13926                "AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL".to_owned(),
13927                "true".to_owned(),
13928            ));
13929        }
13930        FakeEnv(vars.into_iter().collect())
13931    }
13932
13933    // A registered `[media]` root boots green under strict_config: a
13934    // media-enabled app no longer fails at boot with `unknown key "media"`.
13935    #[test]
13936    fn strict_config_accepts_registered_plugin_root() {
13937        let temp = tempfile::tempdir().unwrap();
13938        std::fs::write(
13939            temp.path().join("autumn.toml"),
13940            "[media]\nqueue = \"media\"\n[media.mediamtx]\napi_base = \"http://localhost:9997\"\n",
13941        )
13942        .unwrap();
13943
13944        let env = strict_prod_env(temp.path(), false);
13945        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
13946        assert!(
13947            res.is_ok(),
13948            "a registered [media] root must boot under strict_config: {res:?}"
13949        );
13950    }
13951
13952    // #2067 boot/deploy parity: a registered QUOTED-DOTTED plugin root — the app
13953    // form of `config_section("my.plugin")`, whose top-level table is
13954    // `["my.plugin"]` — is exempted at app-boot strict too. The exemption keys on
13955    // the RAW table key (`plugin_config_roots.contains("my.plugin")`) with
13956    // `path.is_empty()`, so the dot in the key name is irrelevant. This documents
13957    // that deploy leniency (which now derives top-level-ness structurally) and
13958    // app boot agree on quoted-dotted top-level roots.
13959    #[test]
13960    fn strict_config_accepts_quoted_dotted_registered_plugin_root() {
13961        let temp = tempfile::tempdir().unwrap();
13962        std::fs::write(
13963            temp.path().join("autumn.toml"),
13964            "[\"my.plugin\"]\nenabled = true\n[\"my.plugin\".nested]\nx = 1\n",
13965        )
13966        .unwrap();
13967
13968        let env = strict_prod_env(temp.path(), false);
13969        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["my.plugin"]));
13970        assert!(
13971            res.is_ok(),
13972            "a registered quoted-dotted top-level root ([\"my.plugin\"]) must boot \
13973             under strict_config, exactly as deploy leniency accepts it: {res:?}"
13974        );
13975    }
13976
13977    // A registered plugin root written as a NON-TABLE (scalar or array) is a
13978    // malformed section, not the opaque `[media]` TABLE `config_section`
13979    // declares. It must NOT be exempted: nothing would deserialize it and the
13980    // app would boot silently on default plugin config, so it stays a strict
13981    // unknown-root HARD failure instead. Only a table-shaped `[media]` is opaque.
13982    #[test]
13983    fn strict_config_rejects_non_table_registered_plugin_root() {
13984        // Scalar misspelling of a registered root (`media = "enabled"` instead of
13985        // the `[media]` table) must hard-fail under strict_config.
13986        let temp = tempfile::tempdir().unwrap();
13987        std::fs::write(temp.path().join("autumn.toml"), "media = \"enabled\"\n").unwrap();
13988
13989        let env = strict_prod_env(temp.path(), false);
13990        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
13991        assert!(
13992            res.is_err(),
13993            "a scalar-valued registered root (media = \"enabled\") must hard-fail \
13994             under strict_config, not be exempted as an opaque table: {res:?}"
13995        );
13996        assert!(
13997            format!("{:?}", res.err().unwrap()).contains("media"),
13998            "error should name the malformed media root"
13999        );
14000
14001        // Array-valued registered root (`media = ["a", "b"]`) is likewise
14002        // malformed and must hard-fail.
14003        let temp_arr = tempfile::tempdir().unwrap();
14004        std::fs::write(
14005            temp_arr.path().join("autumn.toml"),
14006            "media = [\"a\", \"b\"]\n",
14007        )
14008        .unwrap();
14009
14010        let env_arr = strict_prod_env(temp_arr.path(), false);
14011        let res_arr =
14012            AutumnConfig::load_with_env_and_plugin_roots(&env_arr, &plugin_roots(&["media"]));
14013        assert!(
14014            res_arr.is_err(),
14015            "an array-valued registered root (media = [\"a\", \"b\"]) must hard-fail \
14016             under strict_config, not be exempted as an opaque table: {res_arr:?}"
14017        );
14018        assert!(
14019            format!("{:?}", res_arr.err().unwrap()).contains("media"),
14020            "error should name the malformed media array root"
14021        );
14022    }
14023
14024    // Without registration the same `[media]` root is still an unknown top-level
14025    // key and hard-fails — the seam is fail-closed, not a blanket allow.
14026    #[test]
14027    fn strict_config_rejects_unregistered_plugin_root() {
14028        let temp = tempfile::tempdir().unwrap();
14029        std::fs::write(
14030            temp.path().join("autumn.toml"),
14031            "[media]\nqueue = \"media\"\n",
14032        )
14033        .unwrap();
14034
14035        let env = strict_prod_env(temp.path(), false);
14036        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
14037        assert!(
14038            res.is_err(),
14039            "an unregistered [media] root must still hard-fail under strict_config"
14040        );
14041        assert!(format!("{:?}", res.err().unwrap()).contains("media"));
14042    }
14043
14044    // Registering `[media]` does not weaken the check for OTHER unknown roots: a
14045    // genuinely-unknown top-level table still hard-fails.
14046    #[test]
14047    fn strict_config_still_rejects_other_unknown_root_when_plugin_registered() {
14048        let temp = tempfile::tempdir().unwrap();
14049        std::fs::write(
14050            temp.path().join("autumn.toml"),
14051            "[media]\nqueue = \"media\"\n\n[definitely_not_a_root]\nx = 1\n",
14052        )
14053        .unwrap();
14054
14055        let env = strict_prod_env(temp.path(), false);
14056        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14057        assert!(
14058            res.is_err(),
14059            "an unrelated unknown root must still hard-fail even with [media] registered"
14060        );
14061        let err_str = format!("{:?}", res.err().unwrap());
14062        assert!(
14063            err_str.contains("definitely_not_a_root"),
14064            "error should name the unknown root: {err_str}"
14065        );
14066    }
14067
14068    // A registered root is OPAQUE: even with `strict_config_enforce_all` set,
14069    // arbitrary nested children of `[media]` are never descended into and so are
14070    // never flagged — the plugin owns validation of its own subtree.
14071    #[test]
14072    fn registered_plugin_root_is_opaque_under_enforce_all() {
14073        let temp = tempfile::tempdir().unwrap();
14074        std::fs::write(
14075            temp.path().join("autumn.toml"),
14076            "[media]\nwholly_made_up = true\n[media.deeply.nested]\nalso_bogus = 42\n",
14077        )
14078        .unwrap();
14079
14080        let env = strict_prod_env(temp.path(), true);
14081        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14082        assert!(
14083            res.is_ok(),
14084            "enforce_all must NOT flag children of a registered opaque root: {res:?}"
14085        );
14086    }
14087
14088    // A registered plugin root under a profile prefix (`[profile.prod.media]`) stays
14089    // strict and must be rejected: the exemption covers only the true top-level
14090    // `[media]` table. The media plugin's reader deserializes only the top-level
14091    // `root.media` and does not apply Autumn's profile merge, so a profile layer the
14092    // plugin cannot consume must not be exempted — otherwise a strict app with media
14093    // settings only under `[profile.prod.media]` would boot silently on default plugin
14094    // config instead of failing loudly. Profile-aware plugin config is a separate,
14095    // larger enhancement.
14096    #[test]
14097    fn strict_config_still_rejects_profile_prefixed_plugin_root() {
14098        let temp = tempfile::tempdir().unwrap();
14099        std::fs::write(
14100            temp.path().join("autumn.toml"),
14101            "[profile.prod.media]\nwholly_made_up = true\n\
14102             [profile.prod.media.deeply.nested]\nalso_bogus = 42\n",
14103        )
14104        .unwrap();
14105
14106        let env = strict_prod_env(temp.path(), true);
14107        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14108        assert!(
14109            res.is_err(),
14110            "a profile-prefixed plugin root ([profile.prod.media]) must stay strict \
14111             and be rejected — the plugin reads only the top-level [media] table, so \
14112             exempting the profile layer would boot silently on default config: {res:?}"
14113        );
14114        let err_str = format!("{:?}", res.err().unwrap());
14115        assert!(
14116            err_str.contains("media"),
14117            "error should name the media/profile root: {err_str}"
14118        );
14119    }
14120
14121    // The profile-prefix opacity is NOT a blanket allow of profile subtrees: a
14122    // genuinely-unknown root under a profile prefix
14123    // (`[profile.prod.definitely_not_a_root]`) still hard-fails, because it is
14124    // validated against the root schema and is not a registered plugin root.
14125    #[test]
14126    fn strict_config_rejects_profile_prefixed_unknown_root() {
14127        let temp = tempfile::tempdir().unwrap();
14128        std::fs::write(
14129            temp.path().join("autumn.toml"),
14130            "[profile.prod.definitely_not_a_root]\nx = 1\n",
14131        )
14132        .unwrap();
14133
14134        let env = strict_prod_env(temp.path(), false);
14135        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14136        assert!(
14137            res.is_err(),
14138            "a profile-prefixed genuinely-unknown root must still hard-fail even \
14139             with [media] registered (the fix must not blanket-allow profile subtrees)"
14140        );
14141        let err_str = format!("{:?}", res.err().unwrap());
14142        assert!(
14143            err_str.contains("definitely_not_a_root"),
14144            "error should name the unknown root: {err_str}"
14145        );
14146    }
14147
14148    // When strict_config is OFF, behavior is unchanged: `[media]` is tolerated
14149    // even with no roots registered (non-strict never ran the check). `prod`
14150    // turns strict_config on (#3057), so the test turns it off explicitly.
14151    #[test]
14152    fn non_strict_config_tolerates_media_root_without_registration() {
14153        let temp = tempfile::tempdir().unwrap();
14154        std::fs::write(
14155            temp.path().join("autumn.toml"),
14156            "[server]\nstrict_config = false\n\n[media]\nqueue = \"media\"\n",
14157        )
14158        .unwrap();
14159
14160        let env = FakeEnv(
14161            [
14162                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14163                (
14164                    "AUTUMN_MANIFEST_DIR".to_owned(),
14165                    temp.path().to_str().unwrap().to_owned(),
14166                ),
14167            ]
14168            .into(),
14169        );
14170        let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
14171        assert!(
14172            res.is_ok(),
14173            "non-strict config must tolerate an unregistered [media] root: {res:?}"
14174        );
14175    }
14176
14177    // 7c′ (#1890 regression guard): a MALFORMED top-level `[profile]` entry (e.g.
14178    // `[profile] dev = "prod"`, whose validation error path is `profile.dev`) is a
14179    // structural error that was always fatal under strict_config. It is NOT a
14180    // section newly revealed by #1890, so the warn-first classifier must keep it
14181    // hard-failing. A genuinely newly-covered section typo (`[resilience]`) with
14182    // the same strict_config (enforce_all OFF) must still only warn — proving the
14183    // fix is narrow and did not over-broaden into hard-failing new sections.
14184    #[test]
14185    fn malformed_profile_entry_still_hard_fails() {
14186        // Malformed profile block: `dev = "prod"` is a scalar where a nested
14187        // profile table is expected -> unknown-key error path `profile.dev`.
14188        let temp = tempfile::tempdir().unwrap();
14189        let config_path = temp.path().join("autumn.toml");
14190        std::fs::write(&config_path, "[profile]\ndev = \"prod\"\n").unwrap();
14191
14192        let env = FakeEnv(
14193            [
14194                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14195                // Non-dev profile so the `dev` smart-defaults' feature-gated
14196                // `[storage]` table isn't injected; this test must fail on the
14197                // malformed `[profile]` entry, not on `storage`.
14198                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14199                (
14200                    "AUTUMN_MANIFEST_DIR".to_owned(),
14201                    temp.path().to_str().unwrap().to_owned(),
14202                ),
14203            ]
14204            .into(),
14205        );
14206
14207        let res = AutumnConfig::load_with_env(&env);
14208        assert!(
14209            res.is_err(),
14210            "a malformed [profile] entry is a structural error that must keep \
14211             hard-failing under strict_config (not be demoted to warn-only): {res:?}"
14212        );
14213        assert!(
14214            matches!(res.err().unwrap(), ConfigError::Validation(_)),
14215            "malformed profile entry must fail as a validation error"
14216        );
14217
14218        // Narrowness guard: a typo in a section that only became strictly
14219        // validated by #1890 (`[resilience]`) must still WARN (not fail) under the
14220        // same strict_config with enforce_all OFF.
14221        let temp2 = tempfile::tempdir().unwrap();
14222        let config_path2 = temp2.path().join("autumn.toml");
14223        std::fs::write(&config_path2, "[resilience]\nboguz = 1\n").unwrap();
14224
14225        let env2 = FakeEnv(
14226            [
14227                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14228                // Non-dev profile so the `dev` smart-defaults' feature-gated
14229                // `[storage]` table isn't injected: the `[resilience]` typo must
14230                // remain a warn-only (Ok) case, not be masked by a hard-failing
14231                // `storage` key when the storage feature is off.
14232                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14233                (
14234                    "AUTUMN_MANIFEST_DIR".to_owned(),
14235                    temp2.path().to_str().unwrap().to_owned(),
14236                ),
14237            ]
14238            .into(),
14239        );
14240
14241        let res2 = AutumnConfig::load_with_env(&env2);
14242        assert!(
14243            res2.is_ok(),
14244            "a newly-#1890-covered section typo must still only warn under \
14245             strict_config (enforce_all off), proving the profile fix is narrow: {res2:?}"
14246        );
14247    }
14248
14249    // 7c″ (#1890 P2 fix): a typo under a quoted dotted profile name, such as
14250    // `[profile."prod.eu".server]`, must classify by its real segment-derived schema
14251    // parent. `"prod.eu"` is one TOML key with a literal dot, so the segmented path is
14252    // `["profile", "prod.eu", "server"]` and the profile-stripped schema parent is
14253    // `server`, a pre-#1890 strict section that must keep hard-failing rather than be
14254    // demoted to warn-only by string-splitting the joined path. A
14255    // `[profile."prod.eu".resilience]` typo, in a newly-#1890-covered section, with the
14256    // same strict_config and enforce_all off must still only warn — proving the fix
14257    // stays narrow under dotted profile names.
14258    #[test]
14259    fn dotted_profile_name_preserves_strictness() {
14260        // Pre-#1890 strict section (`server`) under a quoted dotted profile name:
14261        // must hard-fail.
14262        let temp = tempfile::tempdir().unwrap();
14263        let config_path = temp.path().join("autumn.toml");
14264        std::fs::write(
14265            &config_path,
14266            "[profile.\"prod.eu\".server]\nbogus_zzz = true\n",
14267        )
14268        .unwrap();
14269
14270        let env = FakeEnv(
14271            [
14272                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14273                // Non-dev profile so the `dev` smart-defaults' feature-gated
14274                // `[storage]` table isn't injected; this test must fail on the
14275                // `[server]` typo, not on `storage` (storage feature off).
14276                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14277                (
14278                    "AUTUMN_MANIFEST_DIR".to_owned(),
14279                    temp.path().to_str().unwrap().to_owned(),
14280                ),
14281            ]
14282            .into(),
14283        );
14284
14285        let res = AutumnConfig::load_with_env(&env);
14286        assert!(
14287            res.is_err(),
14288            "a [server] typo under a quoted dotted profile name must hard-fail \
14289             (pre-#1890 strictness must not be downgraded by string-splitting the \
14290             joined path): {res:?}"
14291        );
14292        let err_str = format!("{:?}", res.err().unwrap());
14293        assert!(
14294            err_str.contains("server") && err_str.contains("bogus_zzz"),
14295            "hard-fail must be for the [server] typo (right reason): {err_str}"
14296        );
14297
14298        // Newly-#1890-covered section (`resilience`) under the same quoted dotted
14299        // profile name: must still only WARN (enforce_all off) — the fix is narrow.
14300        let temp2 = tempfile::tempdir().unwrap();
14301        let config_path2 = temp2.path().join("autumn.toml");
14302        std::fs::write(
14303            &config_path2,
14304            "[profile.\"prod.eu\".resilience]\nboguz = 1\n",
14305        )
14306        .unwrap();
14307
14308        let env2 = FakeEnv(
14309            [
14310                ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14311                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14312                (
14313                    "AUTUMN_MANIFEST_DIR".to_owned(),
14314                    temp2.path().to_str().unwrap().to_owned(),
14315                ),
14316            ]
14317            .into(),
14318        );
14319
14320        let res2 = AutumnConfig::load_with_env(&env2);
14321        assert!(
14322            res2.is_ok(),
14323            "a newly-#1890-covered section typo under a quoted dotted profile name \
14324             must still only warn under strict_config (enforce_all off): {res2:?}"
14325        );
14326    }
14327
14328    // 7d (#1890): the `database.statement_timeout` duration field — whose empty
14329    // probe used to abort the schema walk — still deserializes correctly at
14330    // runtime, in both string and integer (milliseconds) forms.
14331    #[test]
14332    fn statement_timeout_duration_field_loads() {
14333        let temp = tempfile::tempdir().unwrap();
14334        let config_path = temp.path().join("autumn.toml");
14335
14336        std::fs::write(&config_path, "[database]\nstatement_timeout = \"30s\"\n").unwrap();
14337        let env = FakeEnv(
14338            [(
14339                "AUTUMN_MANIFEST_DIR".to_owned(),
14340                temp.path().to_str().unwrap().to_owned(),
14341            )]
14342            .into(),
14343        );
14344        let config =
14345            AutumnConfig::load_with_env(&env).expect("config with duration string must load");
14346        assert_eq!(
14347            config.database.statement_timeout,
14348            Some(std::time::Duration::from_secs(30))
14349        );
14350
14351        // Integer form is interpreted as milliseconds.
14352        std::fs::write(&config_path, "[database]\nstatement_timeout = 250\n").unwrap();
14353        let config =
14354            AutumnConfig::load_with_env(&env).expect("config with integer duration must load");
14355        assert_eq!(
14356            config.database.statement_timeout,
14357            Some(std::time::Duration::from_millis(250))
14358        );
14359    }
14360
14361    #[test]
14362    fn should_warn_total_connections_at_and_above_threshold() {
14363        // At or above the threshold warns; below does not.
14364        assert!(should_warn_total_connections(100, 100));
14365        assert!(should_warn_total_connections(250, 100));
14366        assert!(!should_warn_total_connections(99, 100));
14367    }
14368
14369    #[test]
14370    fn should_warn_total_connections_zero_threshold_disables() {
14371        // A zero threshold silences the warning regardless of the total.
14372        assert!(!should_warn_total_connections(0, 0));
14373        assert!(!should_warn_total_connections(10_000, 0));
14374    }
14375
14376    #[test]
14377    fn database_config_default_warn_threshold_is_100() {
14378        assert_eq!(
14379            DatabaseConfig::default().max_connections_warn_threshold,
14380            100
14381        );
14382    }
14383
14384    /// Mock loader for tests — returns a hand-built config without touching disk.
14385    struct MockConfigLoader {
14386        config: AutumnConfig,
14387    }
14388
14389    impl ConfigLoader for MockConfigLoader {
14390        async fn load(&self) -> Result<AutumnConfig, ConfigError> {
14391            Ok(self.config.clone())
14392        }
14393    }
14394
14395    #[tokio::test]
14396    async fn config_loader_trait_returns_supplied_config() {
14397        let mut custom = AutumnConfig::default();
14398        custom.server.port = 9999;
14399        custom.profile = Some("integration-test".to_owned());
14400
14401        let loader = MockConfigLoader {
14402            config: custom.clone(),
14403        };
14404        let resolved = loader.load().await.expect("mock loader should succeed");
14405
14406        assert_eq!(resolved.server.port, 9999);
14407        assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
14408    }
14409
14410    #[test]
14411    fn validate_does_not_error_on_redis_backend_without_url() {
14412        // Regression: previously `validate()` called
14413        // `session.backend_plan(profile)` which returned an error for
14414        // `backend = "redis"` without `redis.url`, exiting the boot before
14415        // a `with_session_store(...)` override could apply. Session
14416        // backend validation now lives in `build_session_layer`, which
14417        // short-circuits when a custom store is installed. `validate()`
14418        // is config-shape-only and must accept this combination.
14419        let mut config = AutumnConfig::default();
14420        config.session.backend = crate::session::SessionBackend::Redis;
14421        config.session.redis.url = None;
14422
14423        config.validate().expect(
14424            "validate() must accept redis-backend-without-url so custom \
14425             session store overrides aren't blocked at boot",
14426        );
14427    }
14428
14429    #[tokio::test]
14430    async fn default_toml_env_loader_succeeds_without_files() {
14431        // No autumn.toml in the test runner's pwd; loader should fall back to
14432        // framework defaults rather than failing.
14433        let loader = TomlEnvConfigLoader::new();
14434        let resolved = loader.load().await.expect("default loader should succeed");
14435        // Default port is 3000 per ServerConfig::default — sanity check.
14436        assert_eq!(resolved.server.port, 3000);
14437    }
14438
14439    #[test]
14440    fn database_config_validate_none() {
14441        let config = DatabaseConfig {
14442            url: None,
14443            ..Default::default()
14444        };
14445        assert!(config.validate().is_ok());
14446    }
14447
14448    #[test]
14449    fn database_config_validate_valid_postgres() {
14450        let config = DatabaseConfig {
14451            url: Some("postgres://user:pass@localhost:5432/db".to_string()),
14452            ..Default::default()
14453        };
14454        assert!(config.validate().is_ok());
14455    }
14456
14457    #[test]
14458    fn database_config_validate_valid_postgresql() {
14459        let config = DatabaseConfig {
14460            url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
14461            ..Default::default()
14462        };
14463        assert!(config.validate().is_ok());
14464    }
14465
14466    #[test]
14467    fn database_config_validate_invalid_scheme() {
14468        let config = DatabaseConfig {
14469            url: Some("mysql://user:pass@localhost:3306/db".to_string()),
14470            ..Default::default()
14471        };
14472        let result = config.validate();
14473        assert!(result.is_err());
14474        match result {
14475            Err(ConfigError::Validation(msg)) => {
14476                // Ensure we just match the underlying variant correctly
14477                // as requested in the review.
14478                assert!(msg.contains("must start with postgres:// or postgresql://"));
14479            }
14480            _ => panic!("Expected ConfigError::Validation"),
14481        }
14482    }
14483
14484    // The boot refusal an ordinary `autumn.toml` misconfiguration hits. It
14485    // reaches `tracing::error!`, so under `log.format = "json"` whatever it
14486    // names lands in the structured log stream — one line after the startup
14487    // summary masked the very same URL.
14488    #[test]
14489    fn database_config_validate_does_not_echo_credentials() {
14490        for url in [
14491            "mysql://user:hunter2@localhost:3306/db",
14492            "redis://:hunter2@localhost:6379",
14493            "amqp://app:hunter2@broker/vhost",
14494        ] {
14495            let config = DatabaseConfig {
14496                url: Some(url.to_owned()),
14497                ..Default::default()
14498            };
14499            let Err(ConfigError::Validation(msg)) = config.validate() else {
14500                panic!("{url} must be refused");
14501            };
14502            assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
14503            // Still actionable: the operator can tell which URL to go fix.
14504            assert!(msg.contains("localhost") || msg.contains("broker"), "{msg}");
14505        }
14506    }
14507
14508    #[test]
14509    fn database_shard_url_validation_does_not_echo_credentials() {
14510        let config = DatabaseConfig {
14511            shards: vec![ShardConfig {
14512                name: "shard0".to_owned(),
14513                primary_url: "mysql://user:hunter2@localhost:3306/db".to_owned(),
14514                slots: None,
14515                replica_url: None,
14516                primary_pool_size: None,
14517                replica_pool_size: None,
14518                replica_fallback: None,
14519            }],
14520            ..Default::default()
14521        };
14522        let Err(ConfigError::Validation(msg)) = config.validate() else {
14523            panic!("a non-Postgres shard url must be refused");
14524        };
14525        assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
14526    }
14527
14528    #[test]
14529    fn server_defaults() {
14530        let config = ServerConfig::default();
14531        assert_eq!(config.port, 3000);
14532        assert_eq!(config.host, "127.0.0.1");
14533        assert_eq!(config.shutdown_timeout_secs, 30);
14534    }
14535
14536    #[test]
14537    fn database_defaults() {
14538        let config = DatabaseConfig::default();
14539        assert!(config.url.is_none());
14540        assert_eq!(config.pool_size, 10);
14541        assert_eq!(config.connect_timeout_secs, 5);
14542    }
14543
14544    #[test]
14545    fn database_validate_none_url_is_ok() {
14546        let config = DatabaseConfig {
14547            url: None,
14548            ..Default::default()
14549        };
14550        assert!(config.validate().is_ok());
14551    }
14552
14553    #[test]
14554    fn database_validate_postgres_url_is_ok() {
14555        let config = DatabaseConfig {
14556            url: Some("postgres://user:pass@localhost/db".to_string()),
14557            ..Default::default()
14558        };
14559        assert!(config.validate().is_ok());
14560    }
14561
14562    #[test]
14563    fn database_validate_postgresql_url_is_ok() {
14564        let config = DatabaseConfig {
14565            url: Some("postgresql://user:pass@localhost/db".to_string()),
14566            ..Default::default()
14567        };
14568        assert!(config.validate().is_ok());
14569    }
14570
14571    #[test]
14572    fn database_validate_invalid_url_is_err() {
14573        let config = DatabaseConfig {
14574            url: Some("mysql://user:pass@localhost/db".to_string()),
14575            ..Default::default()
14576        };
14577        let result = config.validate();
14578        assert!(result.is_err());
14579        if let Err(ConfigError::Validation(msg)) = result {
14580            assert!(msg.contains("Invalid database URL"));
14581            assert!(msg.contains("must start with postgres:// or postgresql://"));
14582        } else {
14583            panic!("Expected ConfigError::Validation");
14584        }
14585    }
14586
14587    #[test]
14588    fn database_topology_deserializes_primary_and_replica_urls() {
14589        let config: AutumnConfig = toml::from_str(
14590            r#"
14591[database]
14592primary_url = "postgres://primary.example/app"
14593replica_url = "postgres://replica.example/app"
14594primary_pool_size = 12
14595replica_pool_size = 4
14596replica_fallback = "primary"
14597"#,
14598        )
14599        .expect("database topology config should parse");
14600
14601        assert_eq!(
14602            config.database.primary_url.as_deref(),
14603            Some("postgres://primary.example/app")
14604        );
14605        assert_eq!(
14606            config.database.replica_url.as_deref(),
14607            Some("postgres://replica.example/app")
14608        );
14609        assert_eq!(config.database.primary_pool_size, Some(12));
14610        assert_eq!(config.database.replica_pool_size, Some(4));
14611        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
14612        assert_eq!(
14613            config.database.effective_primary_url(),
14614            Some("postgres://primary.example/app")
14615        );
14616        assert_eq!(config.database.effective_primary_pool_size(), 12);
14617        assert_eq!(config.database.effective_replica_pool_size(), 4);
14618    }
14619
14620    #[test]
14621    fn database_topology_keeps_url_as_single_primary_compatibility_path() {
14622        let config: AutumnConfig = toml::from_str(
14623            r#"
14624[database]
14625url = "postgres://single.example/app"
14626pool_size = 7
14627"#,
14628        )
14629        .expect("legacy database.url config should parse");
14630
14631        assert_eq!(
14632            config.database.effective_primary_url(),
14633            Some("postgres://single.example/app")
14634        );
14635        assert_eq!(config.database.effective_primary_pool_size(), 7);
14636        assert_eq!(config.database.effective_replica_pool_size(), 7);
14637        assert!(config.database.replica_url.is_none());
14638    }
14639
14640    #[test]
14641    fn database_topology_rejects_replica_without_primary() {
14642        let config = DatabaseConfig {
14643            replica_url: Some("postgres://replica.example/app".to_owned()),
14644            ..Default::default()
14645        };
14646
14647        let result = config.validate();
14648
14649        assert!(result.is_err());
14650        let Err(ConfigError::Validation(message)) = result else {
14651            panic!("expected database topology validation error");
14652        };
14653        assert!(message.contains("database.replica_url"));
14654        assert!(message.contains("database.primary_url"));
14655    }
14656
14657    #[test]
14658    fn time_zone_identifier_env_override_applies() {
14659        let env = MockEnv::new().with("AUTUMN_TIME_ZONE__IDENTIFIER", "America/New_York");
14660        let mut config = AutumnConfig::default();
14661        assert_eq!(config.time_zone.identifier, "UTC");
14662
14663        config.apply_env_overrides_with_env(&env);
14664
14665        assert_eq!(config.time_zone.identifier, "America/New_York");
14666        assert!(config.time_zone.validate().is_ok());
14667    }
14668
14669    #[test]
14670    fn alerts_severities_env_overrides_apply() {
14671        // Per-channel severity routing must be controllable via the documented
14672        // AUTUMN_ALERTS__*_SEVERITIES overrides, using the same `all`/`critical`
14673        // value parsing the TOML/file path uses.
14674        let env = MockEnv::new()
14675            .with("AUTUMN_ALERTS__SLACK_SEVERITIES", "critical")
14676            .with("AUTUMN_ALERTS__PAGERDUTY_SEVERITIES", "all")
14677            .with("AUTUMN_ALERTS__DISCORD_SEVERITIES", "critical");
14678        let mut config = AutumnConfig::default();
14679        // Defaults are `All` for every channel.
14680        assert_eq!(
14681            config.alerts.slack_severities,
14682            crate::alerts::AlertRouting::All
14683        );
14684
14685        config.apply_env_overrides_with_env(&env);
14686
14687        assert_eq!(
14688            config.alerts.slack_severities,
14689            crate::alerts::AlertRouting::Critical,
14690            "AUTUMN_ALERTS__SLACK_SEVERITIES=critical must set the Slack channel routing"
14691        );
14692        assert_eq!(
14693            config.alerts.discord_severities,
14694            crate::alerts::AlertRouting::Critical
14695        );
14696        assert_eq!(
14697            config.alerts.pagerduty_severities,
14698            crate::alerts::AlertRouting::All
14699        );
14700    }
14701
14702    #[test]
14703    fn database_topology_env_overrides_role_fields() {
14704        let env = MockEnv::new()
14705            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
14706            .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
14707            .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
14708            .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
14709            .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
14710        let mut config = AutumnConfig::default();
14711
14712        config.apply_env_overrides_with_env(&env);
14713
14714        assert_eq!(
14715            config.database.primary_url.as_deref(),
14716            Some("postgres://primary.env/app")
14717        );
14718        assert_eq!(
14719            config.database.replica_url.as_deref(),
14720            Some("postgres://replica.env/app")
14721        );
14722        assert_eq!(config.database.primary_pool_size, Some(9));
14723        assert_eq!(config.database.replica_pool_size, Some(3));
14724        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
14725    }
14726
14727    #[test]
14728    fn database_shards_parse_from_toml_with_effective_fallbacks() {
14729        let config: AutumnConfig = toml::from_str(
14730            r#"
14731[database]
14732primary_url = "postgres://control.example/app"
14733pool_size = 8
14734replica_fallback = "primary"
14735
14736[[database.shards]]
14737name = "shard0"
14738primary_url = "postgres://shard0.example/app"
14739
14740[[database.shards]]
14741name = "shard1"
14742primary_url = "postgres://shard1.example/app"
14743replica_url = "postgres://shard1-ro.example/app"
14744primary_pool_size = 3
14745replica_pool_size = 2
14746replica_fallback = "fail_readiness"
14747"#,
14748        )
14749        .expect("sharded database config should parse");
14750
14751        let db = &config.database;
14752        assert!(db.has_shards());
14753        assert_eq!(db.shards.len(), 2);
14754
14755        let shard0 = &db.shards[0];
14756        assert_eq!(shard0.name, "shard0");
14757        assert_eq!(shard0.primary_url, "postgres://shard0.example/app");
14758        assert!(shard0.replica_url.is_none());
14759        // Unset shard fields fall back to the [database] defaults.
14760        assert_eq!(shard0.effective_primary_pool_size(db), 8);
14761        assert_eq!(shard0.effective_replica_pool_size(db), 8);
14762        assert_eq!(
14763            shard0.effective_replica_fallback(db),
14764            ReplicaFallback::Primary
14765        );
14766
14767        let shard1 = &db.shards[1];
14768        assert_eq!(shard1.effective_primary_pool_size(db), 3);
14769        assert_eq!(shard1.effective_replica_pool_size(db), 2);
14770        assert_eq!(
14771            shard1.effective_replica_fallback(db),
14772            ReplicaFallback::FailReadiness
14773        );
14774
14775        config.validate().expect("sharded config should validate");
14776    }
14777
14778    #[test]
14779    fn database_shards_default_to_empty() {
14780        let config = AutumnConfig::default();
14781        assert!(!config.database.has_shards());
14782        assert!(config.database.shards.is_empty());
14783    }
14784
14785    #[test]
14786    fn database_shard_env_overrides_existing_entry_fields() {
14787        let mut config: AutumnConfig = toml::from_str(
14788            r#"
14789[[database.shards]]
14790name = "shard0"
14791primary_url = "postgres://toml.example/app"
14792"#,
14793        )
14794        .expect("config should parse");
14795        let env = MockEnv::new()
14796            .with(
14797                "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
14798                "postgres://env.example/app",
14799            )
14800            .with(
14801                "AUTUMN_DATABASE__SHARDS__0__REPLICA_URL",
14802                "postgres://env-ro.example/app",
14803            )
14804            .with("AUTUMN_DATABASE__SHARDS__0__PRIMARY_POOL_SIZE", "5")
14805            .with("AUTUMN_DATABASE__SHARDS__0__REPLICA_FALLBACK", "primary");
14806
14807        config.apply_env_overrides_with_env(&env);
14808
14809        let shard = &config.database.shards[0];
14810        assert_eq!(shard.name, "shard0");
14811        assert_eq!(shard.primary_url, "postgres://env.example/app");
14812        assert_eq!(
14813            shard.replica_url.as_deref(),
14814            Some("postgres://env-ro.example/app")
14815        );
14816        assert_eq!(shard.primary_pool_size, Some(5));
14817        assert_eq!(shard.replica_fallback, Some(ReplicaFallback::Primary));
14818    }
14819
14820    #[test]
14821    fn database_shard_env_appends_new_entry_when_name_and_primary_url_present() {
14822        let mut config = AutumnConfig::default();
14823        let env = MockEnv::new()
14824            .with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0")
14825            .with(
14826                "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
14827                "postgres://shard0.env/app",
14828            )
14829            .with("AUTUMN_DATABASE__SHARDS__1__NAME", "shard1")
14830            .with(
14831                "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
14832                "postgres://shard1.env/app",
14833            )
14834            // Index 3 is unreachable because index 2 is absent: probing stops.
14835            .with("AUTUMN_DATABASE__SHARDS__3__NAME", "orphan")
14836            .with(
14837                "AUTUMN_DATABASE__SHARDS__3__PRIMARY_URL",
14838                "postgres://orphan.env/app",
14839            );
14840
14841        config.apply_env_overrides_with_env(&env);
14842
14843        assert_eq!(config.database.shards.len(), 2);
14844        assert_eq!(config.database.shards[0].name, "shard0");
14845        assert_eq!(config.database.shards[1].name, "shard1");
14846    }
14847
14848    #[test]
14849    fn database_shard_env_does_not_append_incomplete_entry() {
14850        let mut config = AutumnConfig::default();
14851        // NAME without PRIMARY_URL is not enough to create a shard.
14852        let env = MockEnv::new().with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0");
14853
14854        config.apply_env_overrides_with_env(&env);
14855
14856        assert!(config.database.shards.is_empty());
14857    }
14858
14859    fn shard(name: &str, primary_url: &str) -> ShardConfig {
14860        ShardConfig {
14861            name: name.to_owned(),
14862            primary_url: primary_url.to_owned(),
14863            slots: None,
14864            replica_url: None,
14865            primary_pool_size: None,
14866            replica_pool_size: None,
14867            replica_fallback: None,
14868        }
14869    }
14870
14871    fn shard_with_slots(name: &str, primary_url: &str, slots: &[&str]) -> ShardConfig {
14872        let mut config = shard(name, primary_url);
14873        config.slots = Some(
14874            slots
14875                .iter()
14876                .map(|spec| SlotSpec::Range((*spec).to_owned()))
14877                .collect(),
14878        );
14879        config
14880    }
14881
14882    #[test]
14883    fn slot_spec_expands_indices_and_ranges() {
14884        assert_eq!(SlotSpec::Index(5).expand().unwrap(), vec![5]);
14885        assert_eq!(SlotSpec::Range("7".to_owned()).expand().unwrap(), vec![7]);
14886        assert_eq!(
14887            SlotSpec::Range("3-6".to_owned()).expand().unwrap(),
14888            vec![3, 4, 5, 6]
14889        );
14890        assert!(SlotSpec::Range("6-3".to_owned()).expand().is_err());
14891        assert!(SlotSpec::Range("x-3".to_owned()).expand().is_err());
14892        assert!(SlotSpec::Range(String::new()).expand().is_err());
14893    }
14894
14895    #[test]
14896    fn slot_map_auto_splits_contiguously_by_declaration_order() {
14897        let config = DatabaseConfig {
14898            shards: vec![
14899                shard("a", "postgres://a/app"),
14900                shard("b", "postgres://b/app"),
14901                shard("c", "postgres://c/app"),
14902            ],
14903            ..Default::default()
14904        };
14905        let map = config
14906            .resolved_slot_map()
14907            .expect("auto-split should resolve");
14908        assert_eq!(map.len(), usize::from(SLOT_COUNT));
14909        // slot * 3 / 16384 — contiguous, near-even thirds.
14910        assert_eq!((map[0], map[5461]), (0, 0));
14911        assert_eq!((map[5462], map[10922]), (1, 1));
14912        assert_eq!((map[10923], map[16383]), (2, 2));
14913        assert!(map.windows(2).all(|w| w[0] <= w[1]), "must be contiguous");
14914        for owner in 0..3 {
14915            let count = map.iter().filter(|&&o| o == owner).count();
14916            assert!(
14917                (5461..=5462).contains(&count),
14918                "shard {owner} owns {count} slots (expected near-even split)"
14919            );
14920        }
14921    }
14922
14923    #[test]
14924    fn slot_map_uses_explicit_assignments_regardless_of_order() {
14925        let config = DatabaseConfig {
14926            shards: vec![
14927                shard_with_slots("late", "postgres://late/app", &["8192-16383"]),
14928                shard_with_slots("early", "postgres://early/app", &["0-8191"]),
14929            ],
14930            ..Default::default()
14931        };
14932        let map = config
14933            .resolved_slot_map()
14934            .expect("explicit map should resolve");
14935        assert!(map[..8192].iter().all(|&owner| owner == 1));
14936        assert!(map[8192..].iter().all(|&owner| owner == 0));
14937    }
14938
14939    #[test]
14940    fn slot_map_allows_drained_shard_with_empty_slots() {
14941        let config = DatabaseConfig {
14942            shards: vec![
14943                shard_with_slots("live", "postgres://live/app", &["0-16383"]),
14944                shard_with_slots("drained", "postgres://drained/app", &[]),
14945            ],
14946            ..Default::default()
14947        };
14948        let map = config
14949            .resolved_slot_map()
14950            .expect("drained shard is allowed");
14951        assert_eq!(map.len(), usize::from(SLOT_COUNT));
14952        assert!(map.iter().all(|&owner| owner == 0));
14953    }
14954
14955    #[test]
14956    fn slot_map_rejects_mixed_declared_and_undeclared_slots() {
14957        let config = DatabaseConfig {
14958            shards: vec![
14959                shard_with_slots("a", "postgres://a/app", &["0-16383"]),
14960                shard("b", "postgres://b/app"),
14961            ],
14962            ..Default::default()
14963        };
14964        assert!(config.resolved_slot_map().is_err());
14965    }
14966
14967    #[test]
14968    fn slot_map_rejects_overlap_gap_and_out_of_range() {
14969        // Overlap.
14970        let config = DatabaseConfig {
14971            shards: vec![
14972                shard_with_slots("a", "postgres://a/app", &["0-8192"]),
14973                shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
14974            ],
14975            ..Default::default()
14976        };
14977        let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
14978            panic!("overlapping slots should fail");
14979        };
14980        assert!(message.contains("already owned"));
14981
14982        // Gap — reported as compact ranges, not thousands of indices.
14983        let config = DatabaseConfig {
14984            shards: vec![
14985                shard_with_slots("a", "postgres://a/app", &["0-8000"]),
14986                shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
14987            ],
14988            ..Default::default()
14989        };
14990        let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
14991            panic!("uncovered slots should fail");
14992        };
14993        assert!(message.contains("unassigned"));
14994        assert!(message.contains("8001-8191"), "got: {message}");
14995
14996        // Out of range.
14997        let config = DatabaseConfig {
14998            shards: vec![shard_with_slots("a", "postgres://a/app", &["0-16384"])],
14999            ..Default::default()
15000        };
15001        assert!(config.resolved_slot_map().is_err());
15002    }
15003
15004    #[test]
15005    fn slot_map_rejects_more_shards_than_slots() {
15006        let config = DatabaseConfig {
15007            shards: (0..=usize::from(SLOT_COUNT))
15008                .map(|i| shard(&format!("s{i}"), "postgres://s/app"))
15009                .collect(),
15010            ..Default::default()
15011        };
15012        let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
15013            panic!("more shards than slots cannot auto-split");
15014        };
15015        assert!(message.contains("at most"), "got: {message}");
15016    }
15017
15018    #[test]
15019    fn slots_parse_from_toml_ints_and_ranges() {
15020        let config: AutumnConfig = toml::from_str(
15021            r#"
15022[[database.shards]]
15023name = "a"
15024primary_url = "postgres://a/app"
15025slots = ["0-8191", 8192, "8193"]
15026
15027[[database.shards]]
15028name = "b"
15029primary_url = "postgres://b/app"
15030slots = ["8194-16383"]
15031"#,
15032        )
15033        .expect("slots config should parse");
15034        let map = config
15035            .database
15036            .resolved_slot_map()
15037            .expect("mixed int/range specs should resolve");
15038        assert!(map[..8194].iter().all(|&owner| owner == 0));
15039        assert!(map[8194..].iter().all(|&owner| owner == 1));
15040        config.validate().expect("config should validate");
15041    }
15042
15043    #[test]
15044    fn slot_env_overrides_assignments() {
15045        let mut config = AutumnConfig::default();
15046        let env = MockEnv::new()
15047            .with("AUTUMN_DATABASE__SHARDS__0__NAME", "a")
15048            .with(
15049                "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
15050                "postgres://a/app",
15051            )
15052            .with("AUTUMN_DATABASE__SHARDS__0__SLOTS", "0-8191, 12288-16383")
15053            .with("AUTUMN_DATABASE__SHARDS__1__NAME", "b")
15054            .with(
15055                "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
15056                "postgres://b/app",
15057            )
15058            .with("AUTUMN_DATABASE__SHARDS__1__SLOTS", "8192-12287");
15059
15060        config.apply_env_overrides_with_env(&env);
15061
15062        let map = config
15063            .database
15064            .resolved_slot_map()
15065            .expect("env slot specs should resolve");
15066        assert!(map[..8192].iter().all(|&owner| owner == 0));
15067        assert!(map[8192..12288].iter().all(|&owner| owner == 1));
15068        assert!(map[12288..].iter().all(|&owner| owner == 0));
15069    }
15070
15071    #[test]
15072    fn slot_ranges_format_compactly() {
15073        assert_eq!(format_slot_ranges(&[]), "");
15074        assert_eq!(format_slot_ranges(&[3]), "3");
15075        assert_eq!(format_slot_ranges(&[0, 1, 2, 5, 7, 8]), "0-2, 5, 7-8");
15076    }
15077
15078    #[test]
15079    fn database_shard_validation_rejects_bad_names() {
15080        for bad_name in ["", "Shard0", "shard 0", "shard:0", "shärd"] {
15081            let config = DatabaseConfig {
15082                shards: vec![shard(bad_name, "postgres://s0.example/app")],
15083                ..Default::default()
15084            };
15085            assert!(
15086                config.validate().is_err(),
15087                "shard name should be rejected: {bad_name:?}"
15088            );
15089        }
15090    }
15091
15092    #[test]
15093    fn database_shard_validation_rejects_duplicate_names() {
15094        let config = DatabaseConfig {
15095            shards: vec![
15096                shard("shard0", "postgres://a.example/app"),
15097                shard("shard0", "postgres://b.example/app"),
15098            ],
15099            ..Default::default()
15100        };
15101        let Err(ConfigError::Validation(message)) = config.validate() else {
15102            panic!("duplicate shard names should fail validation");
15103        };
15104        assert!(message.contains("unique"));
15105    }
15106
15107    #[test]
15108    fn database_shard_validation_rejects_bad_urls() {
15109        let config = DatabaseConfig {
15110            shards: vec![shard("shard0", "mysql://s0.example/app")],
15111            ..Default::default()
15112        };
15113        assert!(config.validate().is_err());
15114
15115        let mut with_bad_replica = shard("shard0", "postgres://s0.example/app");
15116        with_bad_replica.replica_url = Some("http://s0-ro.example/app".to_owned());
15117        let config = DatabaseConfig {
15118            shards: vec![with_bad_replica],
15119            ..Default::default()
15120        };
15121        assert!(config.validate().is_err());
15122    }
15123
15124    #[test]
15125    fn database_shards_without_control_role_are_allowed() {
15126        let config = DatabaseConfig {
15127            shards: vec![shard("shard0", "postgres://s0.example/app")],
15128            ..Default::default()
15129        };
15130        config
15131            .validate()
15132            .expect("shards without a control role should validate");
15133    }
15134
15135    #[test]
15136    fn postgres_scheduler_with_shards_requires_control_database() {
15137        let mut config = AutumnConfig::default();
15138        config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15139        config.scheduler.backend = SchedulerBackend::Postgres;
15140
15141        let Err(ConfigError::Validation(message)) = config.validate() else {
15142            panic!("postgres scheduler without a control database should fail validation");
15143        };
15144        assert!(message.contains("control database"));
15145
15146        config.database.primary_url = Some("postgres://control.example/app".to_owned());
15147        config
15148            .validate()
15149            .expect("control role should satisfy the scheduler requirement");
15150    }
15151
15152    #[test]
15153    fn postgres_jobs_with_shards_requires_control_database() {
15154        let mut config = AutumnConfig::default();
15155        config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15156        config.jobs.backend = "postgres".to_owned();
15157
15158        assert!(config.validate().is_err());
15159
15160        config.database.url = Some("postgres://control.example/app".to_owned());
15161        config
15162            .validate()
15163            .expect("legacy url should satisfy the jobs requirement");
15164    }
15165
15166    #[test]
15167    fn shard_local_jobs_need_postgres_jobs_and_shards() {
15168        let mut config = AutumnConfig::default();
15169        config.jobs.postgres.shard_local = true;
15170        let Err(ConfigError::Validation(message)) = config.validate() else {
15171            panic!("shard_local without postgres jobs and shards should fail validation");
15172        };
15173        assert!(message.contains("shard_local"), "{message}");
15174
15175        config.jobs.backend = "postgres".to_owned();
15176        assert!(config.validate().is_err(), "shard_local without shards");
15177
15178        config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15179        config.database.primary_url = Some("postgres://control.example/app".to_owned());
15180        config
15181            .validate()
15182            .expect("postgres jobs with shards satisfy shard_local");
15183    }
15184
15185    #[test]
15186    fn database_validate_url_edge_cases() {
15187        let invalid_urls = vec![
15188            "POSTGRES://localhost/db",
15189            "postgres:/localhost/db",
15190            "postgres:localhost/db",
15191            "http://postgres",
15192            "   postgres://localhost/db",
15193            "",
15194        ];
15195
15196        for invalid_url in invalid_urls {
15197            let config = DatabaseConfig {
15198                url: Some(invalid_url.to_string()),
15199                ..Default::default()
15200            };
15201            assert!(
15202                config.validate().is_err(),
15203                "URL should be invalid: {invalid_url}"
15204            );
15205        }
15206    }
15207
15208    #[test]
15209    fn autumn_config_validate_ok() {
15210        let config = AutumnConfig::default();
15211        assert!(config.validate().is_ok());
15212    }
15213
15214    #[test]
15215    fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
15216        // Session backend validation moved to `build_session_layer`, so a custom store
15217        // installed via `AppBuilder::with_session_store(...)` can override an otherwise
15218        // invalid backend config without the boot exiting first. `validate()` is
15219        // config-shape-only now; runtime session selection, and the backend error, live
15220        // in `build_session_layer`, which short-circuits when a custom store is
15221        // installed. `crate::session::tests::session_backend_plan_*` still cover the
15222        // error cases directly on `SessionConfig::backend_plan`.
15223        let mut config = AutumnConfig::default();
15224        config.session.backend = crate::session::SessionBackend::Redis;
15225        config.session.redis.url = None;
15226
15227        config
15228            .validate()
15229            .expect("validate() must accept invalid session backend so custom store can override");
15230    }
15231
15232    #[test]
15233    fn autumn_config_validate_database_err() {
15234        let mut config = AutumnConfig::default();
15235        config.database.url = Some("mysql://localhost/test".to_string());
15236        assert!(config.validate().is_err());
15237    }
15238
15239    #[test]
15240    fn log_defaults() {
15241        let config = LogConfig::default();
15242        assert_eq!(config.level, "info");
15243        assert_eq!(config.format, LogFormat::Auto);
15244    }
15245
15246    #[test]
15247    fn telemetry_defaults() {
15248        let config = TelemetryConfig::default();
15249        assert!(!config.enabled);
15250        assert_eq!(config.service_name, "autumn-app");
15251        assert!(config.service_namespace.is_none());
15252        assert_eq!(config.service_version, "unknown");
15253        assert_eq!(config.environment, "development");
15254        assert!(config.otlp_endpoint.is_none());
15255        assert_eq!(config.protocol, TelemetryProtocol::Grpc);
15256        assert!(!config.strict);
15257    }
15258
15259    #[test]
15260    fn health_defaults() {
15261        let config = HealthConfig::default();
15262        assert_eq!(config.path, "/health");
15263        assert_eq!(config.live_path, "/live");
15264        assert_eq!(config.ready_path, "/ready");
15265        assert_eq!(config.startup_path, "/startup");
15266        assert!(!config.detailed);
15267    }
15268
15269    #[test]
15270    fn top_level_default_populates_all_sections() {
15271        let config = AutumnConfig::default();
15272        assert_eq!(config.server.port, 3000);
15273        assert!(config.database.url.is_none());
15274        assert_eq!(config.log.level, "info");
15275        assert_eq!(config.health.path, "/health");
15276    }
15277
15278    #[test]
15279    fn deserialize_empty_object_uses_all_defaults() {
15280        let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
15281        assert_eq!(config.server.port, 3000);
15282        assert_eq!(config.server.host, "127.0.0.1");
15283        assert_eq!(config.server.shutdown_timeout_secs, 30);
15284        assert!(config.database.url.is_none());
15285        assert_eq!(config.database.pool_size, 10);
15286        assert_eq!(config.database.connect_timeout_secs, 5);
15287        assert!(!config.database.auto_migrate_in_production);
15288        assert_eq!(config.log.level, "info");
15289        assert_eq!(config.log.format, LogFormat::Auto);
15290        assert_eq!(config.health.path, "/health");
15291    }
15292
15293    #[test]
15294    fn deserialize_partial_config_merges_with_defaults() {
15295        let json = r#"{"server": {"port": 8080}}"#;
15296        let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
15297        assert_eq!(config.server.port, 8080);
15298        assert_eq!(config.server.host, "127.0.0.1");
15299        assert_eq!(config.database.pool_size, 10);
15300        assert_eq!(config.log.level, "info");
15301    }
15302
15303    #[test]
15304    fn log_format_variants_deserialize() {
15305        let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
15306        let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
15307        let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
15308        assert_eq!(auto, LogFormat::Auto);
15309        assert_eq!(pretty, LogFormat::Pretty);
15310        assert_eq!(json, LogFormat::Json);
15311    }
15312
15313    // ── TOML loading tests ───────────────────────────────────────────
15314
15315    #[test]
15316    fn load_missing_file_returns_defaults() {
15317        let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
15318        assert_eq!(config.server.port, 3000);
15319        assert!(config.database.url.is_none());
15320    }
15321
15322    #[test]
15323    fn load_valid_full_config() {
15324        let dir = tempfile::tempdir().unwrap();
15325        let path = dir.path().join("autumn.toml");
15326        std::fs::write(
15327            &path,
15328            r#"
15329[server]
15330port = 8080
15331host = "0.0.0.0"
15332shutdown_timeout_secs = 60
15333
15334[database]
15335url = "postgres://user:pass@db:5432/myapp"
15336pool_size = 20
15337connect_timeout_secs = 10
15338auto_migrate_in_production = true
15339
15340[log]
15341level = "debug"
15342format = "Json"
15343
15344[health]
15345path = "/healthz"
15346"#,
15347        )
15348        .unwrap();
15349
15350        let config = AutumnConfig::load_from(&path).unwrap();
15351        assert_eq!(config.server.port, 8080);
15352        assert_eq!(config.server.host, "0.0.0.0");
15353        assert_eq!(config.server.shutdown_timeout_secs, 60);
15354        assert_eq!(
15355            config.database.url.as_deref(),
15356            Some("postgres://user:pass@db:5432/myapp")
15357        );
15358        assert_eq!(config.database.pool_size, 20);
15359        assert_eq!(config.database.connect_timeout_secs, 10);
15360        assert!(config.database.auto_migrate_in_production);
15361        assert_eq!(config.log.level, "debug");
15362        assert_eq!(config.log.format, LogFormat::Json);
15363        assert_eq!(config.health.path, "/healthz");
15364    }
15365
15366    #[test]
15367    fn load_partial_config_merges_with_defaults() {
15368        let dir = tempfile::tempdir().unwrap();
15369        let path = dir.path().join("autumn.toml");
15370        std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
15371
15372        let config = AutumnConfig::load_from(&path).unwrap();
15373        assert_eq!(config.server.port, 9090);
15374        assert_eq!(config.server.host, "127.0.0.1");
15375        assert_eq!(config.database.pool_size, 10);
15376        assert_eq!(config.log.level, "info");
15377    }
15378
15379    #[test]
15380    fn access_log_defaults_on_with_probe_and_asset_exclusions() {
15381        let log = LogConfig::default();
15382        assert!(log.access_log);
15383        assert_eq!(
15384            log.access_log_exclude,
15385            vec![
15386                "/health",
15387                "/live",
15388                "/ready",
15389                "/startup",
15390                "/actuator",
15391                "/static"
15392            ]
15393        );
15394    }
15395
15396    #[test]
15397    fn env_override_access_log_off() {
15398        let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
15399        let mut config = AutumnConfig::default();
15400        config.apply_env_overrides_with_env(&env);
15401        assert!(!config.log.access_log);
15402    }
15403
15404    #[test]
15405    fn env_override_access_log_exclude_csv() {
15406        let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
15407        let mut config = AutumnConfig::default();
15408        config.apply_env_overrides_with_env(&env);
15409        assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
15410    }
15411
15412    #[test]
15413    fn access_log_is_configurable_from_toml() {
15414        let dir = tempfile::tempdir().unwrap();
15415        let path = dir.path().join("autumn.toml");
15416        std::fs::write(
15417            &path,
15418            "[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
15419        )
15420        .unwrap();
15421
15422        let config = AutumnConfig::load_from(&path).unwrap();
15423        assert!(!config.log.access_log);
15424        assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
15425    }
15426
15427    #[test]
15428    fn load_invalid_toml_returns_error() {
15429        let dir = tempfile::tempdir().unwrap();
15430        let path = dir.path().join("autumn.toml");
15431        std::fs::write(&path, "not valid [[[toml").unwrap();
15432
15433        let result = AutumnConfig::load_from(&path);
15434        assert!(result.is_err());
15435        let err = result.unwrap_err();
15436        assert!(err.to_string().contains("invalid autumn.toml"));
15437    }
15438
15439    #[test]
15440    fn load_empty_file_returns_defaults() {
15441        let dir = tempfile::tempdir().unwrap();
15442        let path = dir.path().join("autumn.toml");
15443        std::fs::write(&path, "").unwrap();
15444
15445        let config = AutumnConfig::load_from(&path).unwrap();
15446        assert_eq!(config.server.port, 3000);
15447    }
15448
15449    // ── Environment variable override tests ──────────────────────
15450
15451    #[test]
15452    fn env_override_database_url() {
15453        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
15454        let mut config = AutumnConfig::default();
15455        config.apply_env_overrides_with_env(&env);
15456        assert_eq!(
15457            config.database.url.as_deref(),
15458            Some("postgres://override:5432/test")
15459        );
15460    }
15461
15462    #[test]
15463    fn metrics_section_defaults_to_the_shipped_caps() {
15464        // The whole point of making the caps configurable is that an app which
15465        // configures nothing keeps the behaviour 0.7.0 shipped.
15466        let config = AutumnConfig::default();
15467        assert_eq!(config.metrics.limits(), crate::metrics::Limits::default());
15468
15469        let parsed: AutumnConfig =
15470            toml::from_str("[server]\nport = 3000\n").expect("config without [metrics] parses");
15471        assert_eq!(parsed.metrics.limits(), crate::metrics::Limits::default());
15472    }
15473
15474    #[test]
15475    fn metrics_section_parses_a_raised_cap() {
15476        let parsed: AutumnConfig =
15477            toml::from_str("[metrics]\nmax_series_per_metric = 500\nmax_labels_per_series = 12\n")
15478                .expect("[metrics] parses");
15479
15480        assert_eq!(parsed.metrics.max_series_per_metric, 500);
15481        assert_eq!(parsed.metrics.max_labels_per_series, 12);
15482        assert_eq!(
15483            parsed.metrics.max_instruments,
15484            crate::metrics::DEFAULT_MAX_INSTRUMENTS,
15485            "an unset key keeps its default rather than zeroing"
15486        );
15487        parsed.metrics.validate().expect("in-range caps validate");
15488    }
15489
15490    #[test]
15491    fn env_override_metrics_caps() {
15492        let env = MockEnv::new()
15493            .with("AUTUMN_METRICS__MAX_SERIES_PER_METRIC", "500")
15494            .with("AUTUMN_METRICS__MAX_INSTRUMENTS", "512")
15495            .with("AUTUMN_METRICS__MAX_LABELS_PER_SERIES", "12");
15496        let mut config = AutumnConfig::default();
15497        config.apply_env_overrides_with_env(&env);
15498
15499        assert_eq!(
15500            config.metrics.limits(),
15501            crate::metrics::Limits {
15502                max_series_per_metric: 500,
15503                max_instruments: 512,
15504                max_labels_per_series: 12,
15505            }
15506        );
15507    }
15508
15509    #[test]
15510    fn validate_rejects_an_out_of_range_metrics_cap() {
15511        // A cap of 0 would drop every labeled sample the app records, silently.
15512        // It must fail the boot — and `autumn check` — by name.
15513        let mut config = AutumnConfig::default();
15514        config.metrics.max_series_per_metric = 0;
15515
15516        let error = config
15517            .validate()
15518            .expect_err("a zero cap must not validate")
15519            .to_string();
15520        assert!(
15521            error.contains("max_series_per_metric"),
15522            "the error must name the key at fault; got {error}"
15523        );
15524
15525        let mut config = AutumnConfig::default();
15526        config.metrics.max_labels_per_series = crate::metrics::MAX_LABELS_PER_SERIES_CEILING + 1;
15527        assert!(
15528            config.validate().is_err(),
15529            "a cap above its ceiling must not validate"
15530        );
15531
15532        AutumnConfig::default()
15533            .validate()
15534            .expect("the default [metrics] section validates");
15535    }
15536
15537    #[test]
15538    fn env_override_actuator_prometheus_disables() {
15539        // Operators must be able to remove the scrape endpoint via the
15540        // documented AUTUMN_SECTION__FIELD convention, not just TOML.
15541        let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
15542        let mut config = AutumnConfig::default();
15543        assert!(config.actuator.prometheus, "default should be enabled");
15544        config.apply_env_overrides_with_env(&env);
15545        assert!(
15546            !config.actuator.prometheus,
15547            "AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
15548        );
15549    }
15550
15551    #[test]
15552    fn env_override_actuator_sensitive() {
15553        let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
15554        let mut config = AutumnConfig::default();
15555        assert!(!config.actuator.sensitive);
15556        config.apply_env_overrides_with_env(&env);
15557        assert!(config.actuator.sensitive);
15558    }
15559
15560    #[test]
15561    fn env_override_upload_reject_on_content_type_mismatch() {
15562        let env = MockEnv::new().with(
15563            "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
15564            "true",
15565        );
15566        let mut config = AutumnConfig::default();
15567        assert!(!config.security.upload.reject_on_content_type_mismatch);
15568        config.apply_env_overrides_with_env(&env);
15569        assert!(config.security.upload.reject_on_content_type_mismatch);
15570    }
15571
15572    #[test]
15573    fn env_override_actuator_prefix() {
15574        let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
15575        let mut config = AutumnConfig::default();
15576        config.apply_env_overrides_with_env(&env);
15577        assert_eq!(config.actuator.prefix, "/ops");
15578    }
15579
15580    #[test]
15581    fn env_override_database_url_wins_over_file_primary_url() {
15582        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
15583        let mut config = AutumnConfig::default();
15584        config.database.primary_url = Some("postgres://file.example/app".to_owned());
15585
15586        config.apply_env_overrides_with_env(&env);
15587
15588        assert_eq!(
15589            config.database.effective_primary_url(),
15590            Some("postgres://env.example/app")
15591        );
15592        assert!(config.database.primary_url.is_none());
15593    }
15594
15595    #[test]
15596    fn env_override_database_primary_url_wins_over_legacy_database_url() {
15597        let env = MockEnv::new()
15598            .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
15599            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
15600        let mut config = AutumnConfig::default();
15601        config.database.primary_url = Some("postgres://file.example/app".to_owned());
15602
15603        config.apply_env_overrides_with_env(&env);
15604
15605        assert_eq!(
15606            config.database.effective_primary_url(),
15607            Some("postgres://primary.env/app")
15608        );
15609        assert_eq!(
15610            config.database.url.as_deref(),
15611            Some("postgres://legacy.env/app")
15612        );
15613    }
15614
15615    #[test]
15616    fn env_override_pool_size() {
15617        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
15618        let mut config = AutumnConfig::default();
15619        config.apply_env_overrides_with_env(&env);
15620        assert_eq!(config.database.pool_size, 25);
15621    }
15622
15623    #[cfg(feature = "reporting")]
15624    #[test]
15625    fn env_override_reporting() {
15626        let env = MockEnv::new()
15627            .with("AUTUMN_REPORTING__ENABLED", "false")
15628            .with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
15629        let mut config = AutumnConfig::default();
15630        assert!(config.reporting.enabled);
15631        assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
15632        config.apply_env_overrides_with_env(&env);
15633        assert!(!config.reporting.enabled);
15634        assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
15635    }
15636
15637    #[cfg(feature = "reporting")]
15638    #[test]
15639    fn failure_capture_defaults_are_off() {
15640        let config = AutumnConfig::default();
15641        assert!(
15642            !config.failure_capture.enabled,
15643            "capture writes production request data to disk; it must be opt-in"
15644        );
15645        assert_eq!(config.failure_capture.dir, "tmp/autumn-capsules");
15646        assert_eq!(config.failure_capture.max_body_bytes, 65_536);
15647        assert_eq!(config.failure_capture.max_capsule_bytes, 1_048_576);
15648        assert_eq!(config.failure_capture.max_capsules, 50);
15649    }
15650
15651    #[cfg(feature = "reporting")]
15652    #[test]
15653    fn failure_capture_env_overrides_apply() {
15654        let env = MockEnv::new()
15655            .with("AUTUMN_FAILURE_CAPTURE__ENABLED", "true")
15656            .with("AUTUMN_FAILURE_CAPTURE__DIR", "/var/tmp/capsules")
15657            .with("AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES", "1024")
15658            .with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES", "2048")
15659            .with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES", "5");
15660        let mut config = AutumnConfig::default();
15661        config.apply_env_overrides_with_env(&env);
15662
15663        assert!(config.failure_capture.enabled);
15664        assert_eq!(config.failure_capture.dir, "/var/tmp/capsules");
15665        assert_eq!(config.failure_capture.max_body_bytes, 1024);
15666        assert_eq!(config.failure_capture.max_capsule_bytes, 2048);
15667        assert_eq!(config.failure_capture.max_capsules, 5);
15668    }
15669
15670    /// Regression guard for the `[failure_capture]` field ordering.
15671    ///
15672    /// Mirrors `deploy_child_keys_are_strictly_validated`: the strict
15673    /// unknown-key validator only descends into a config.rs-internal section
15674    /// declared *before* `database`, because `DatabaseConfig`'s
15675    /// `deserialize_with` duration field aborts the schema walk. If someone
15676    /// moves `failure_capture` below `database`, its child keys silently
15677    /// vanish from the schema and this fails.
15678    #[cfg(feature = "reporting")]
15679    #[test]
15680    fn failure_capture_child_keys_are_strictly_validated() {
15681        let leaves = AutumnConfig::schema_leaf_paths();
15682        for key in [
15683            "failure_capture.enabled",
15684            "failure_capture.dir",
15685            "failure_capture.max_body_bytes",
15686            "failure_capture.max_capsule_bytes",
15687            "failure_capture.max_capsules",
15688        ] {
15689            assert!(
15690                leaves.contains(key),
15691                "{key} must be a schema leaf so strict validation descends into \
15692                 [failure_capture]; if this fails, the section was likely moved below \
15693                 `database` in AutumnConfig"
15694            );
15695        }
15696
15697        let schema = AutumnConfig::get_schema_keys();
15698        let errors = AutumnConfig::validate_toml("[failure_capture]\nenabledd = true\n", &schema);
15699        assert!(
15700            errors
15701                .iter()
15702                .any(|(path, _)| path == "failure_capture.enabledd"),
15703            "a bogus [failure_capture] child key must be rejected by strict validation, \
15704             got: {errors:?}"
15705        );
15706    }
15707
15708    // ── [cluster] (issue #1762) ──────────────────────────────────────────
15709
15710    /// A cluster with no shared secret is an unauthenticated cluster: anyone
15711    /// who can reach the port can inject state. Boot must fail, not warn.
15712    #[test]
15713    fn cluster_enabled_requires_secret() {
15714        let mut config = AutumnConfig::default();
15715        config.cluster.enabled = true;
15716        config.cluster.secret = None;
15717
15718        let result = config.cluster.validate();
15719        assert!(
15720            result.is_err(),
15721            "[cluster] enabled = true with no secret must fail validation, got {result:?}"
15722        );
15723        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15724        assert!(
15725            message.contains("secret"),
15726            "the error must name the missing key so an operator can fix it; got {message:?}"
15727        );
15728
15729        // Disabled clusters need nothing: the default config must still boot.
15730        assert!(
15731            AutumnConfig::default().cluster.validate().is_ok(),
15732            "a disabled [cluster] section must validate with no secret at all"
15733        );
15734    }
15735
15736    #[test]
15737    fn cluster_rejects_short_secret() {
15738        let mut config = AutumnConfig::default();
15739        config.cluster.enabled = true;
15740        config.cluster.secret = Some(secrecy::SecretString::from("too-short".to_owned()));
15741
15742        let result = config.cluster.validate();
15743        assert!(
15744            result.is_err(),
15745            "a secret shorter than {MIN_CLUSTER_SECRET_LEN} bytes must be refused, got {result:?}"
15746        );
15747
15748        // …and a long-enough secret must be accepted, or the rule is vacuous.
15749        config.cluster.secret = Some(secrecy::SecretString::from(
15750            "a-perfectly-adequate-cluster-secret".to_owned(),
15751        ));
15752        assert!(
15753            config.cluster.validate().is_ok(),
15754            "a secret of at least {MIN_CLUSTER_SECRET_LEN} bytes must be accepted"
15755        );
15756    }
15757
15758    /// Anti-flap hysteresis: a suspicion timeout under three push intervals
15759    /// turns one lost packet into a membership change.
15760    #[test]
15761    fn cluster_rejects_suspicion_below_3x_push_interval() {
15762        let mut config = AutumnConfig::default();
15763        config.cluster.enabled = true;
15764        config.cluster.secret = Some(secrecy::SecretString::from(
15765            "a-perfectly-adequate-cluster-secret".to_owned(),
15766        ));
15767        config.cluster.push_interval_ms = 500;
15768        config.cluster.suspicion_timeout_ms = 1_000;
15769
15770        let result = config.cluster.validate();
15771        assert!(
15772            result.is_err(),
15773            "suspicion_timeout_ms must be at least {MIN_CLUSTER_SUSPICION_MULTIPLE}x \
15774             push_interval_ms, got {result:?}"
15775        );
15776
15777        config.cluster.suspicion_timeout_ms = 1_500;
15778        assert!(
15779            config.cluster.validate().is_ok(),
15780            "exactly {MIN_CLUSTER_SUSPICION_MULTIPLE}x the push interval must be accepted"
15781        );
15782
15783        // A push interval below the floor is refused too.
15784        config.cluster.push_interval_ms = 1;
15785        config.cluster.suspicion_timeout_ms = 3;
15786        assert!(
15787            config.cluster.validate().is_err(),
15788            "push_interval_ms below {MIN_CLUSTER_PUSH_INTERVAL_MS}ms must be refused"
15789        );
15790    }
15791
15792    /// Counter cells are keyed `"{node_id}#{incarnation}"`, so a `#` in a node
15793    /// id (or a cluster name, which is signed alongside it) makes the key
15794    /// ambiguous. Validation refuses it rather than shipping a counter whose
15795    /// cells can collide.
15796    #[test]
15797    fn cluster_rejects_node_id_with_cell_separator() {
15798        let mut config = AutumnConfig::default();
15799        config.cluster.enabled = true;
15800        config.cluster.secret = Some(secrecy::SecretString::from(
15801            "a-perfectly-adequate-cluster-secret".to_owned(),
15802        ));
15803
15804        config.cluster.node_id = Some("node#1".to_owned());
15805        assert!(
15806            config.cluster.validate().is_err(),
15807            "a node_id containing '#' must be refused: it is the counter cell-key separator"
15808        );
15809
15810        config.cluster.node_id = Some("node-1".to_owned());
15811        config.cluster.cluster_name = "orch#ard".to_owned();
15812        assert!(
15813            config.cluster.validate().is_err(),
15814            "a cluster_name containing '#' must be refused for the same reason"
15815        );
15816
15817        config.cluster.cluster_name = "orchard".to_owned();
15818        assert!(
15819            config.cluster.validate().is_ok(),
15820            "…and separator-free names must be accepted, or the rule is vacuous"
15821        );
15822    }
15823
15824    /// Port `0` means "any free port". That is a legal *bind*, because the node
15825    /// advertises the port it was actually given — but it is never a legal
15826    /// thing to publish or to dial, and a peer pointed at port 0 fails in a way
15827    /// that reads as a network fault rather than as a typo.
15828    #[test]
15829    fn cluster_rejects_ephemeral_port_where_a_peer_must_dial() {
15830        let mut config = AutumnConfig::default();
15831        config.cluster.enabled = true;
15832        config.cluster.secret = Some(secrecy::SecretString::from(
15833            "a-perfectly-adequate-cluster-secret".to_owned(),
15834        ));
15835
15836        // An ephemeral BIND is the documented default and must keep working.
15837        config.cluster.bind_addr = "127.0.0.1:0".to_owned();
15838        assert!(
15839            config.cluster.validate().is_ok(),
15840            "bind_addr with port 0 is the ephemeral-bind spelling and must be accepted"
15841        );
15842
15843        config.cluster.advertise_addr = Some("10.0.0.4:0".to_owned());
15844        let result = config.cluster.validate();
15845        assert!(
15846            result.is_err(),
15847            "an explicit advertise_addr on port 0 must be refused: peers dial it verbatim, \
15848             got {result:?}"
15849        );
15850        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15851        assert!(
15852            message.contains("cluster.advertise_addr"),
15853            "the error must name the offending key; got {message:?}"
15854        );
15855
15856        config.cluster.advertise_addr = Some("10.0.0.4:7946".to_owned());
15857        assert!(
15858            config.cluster.validate().is_ok(),
15859            "…and a real advertised port must be accepted, or the rule is vacuous"
15860        );
15861
15862        config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:0".to_owned()];
15863        let result = config.cluster.validate();
15864        assert!(
15865            result.is_err(),
15866            "a seed peer on port 0 is equally undialable and must be refused, got {result:?}"
15867        );
15868        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15869        assert!(
15870            message.contains("cluster.seed_peers[1]"),
15871            "the error must name which seed is wrong; got {message:?}"
15872        );
15873
15874        config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned()];
15875        assert!(
15876            config.cluster.validate().is_ok(),
15877            "…and dialable seeds must be accepted"
15878        );
15879    }
15880
15881    /// `0.0.0.0` is a bind, never a dial address. A node that gossips it hands
15882    /// its peer an address nothing can reach — the one-way cluster the guide's
15883    /// "Choosing addresses" section warns about, which then looks exactly like
15884    /// a network fault from the other side rather than like the typo it is.
15885    #[test]
15886    fn cluster_rejects_a_wildcard_advertised_address() {
15887        let mut config = AutumnConfig::default();
15888        config.cluster.enabled = true;
15889        config.cluster.secret = Some(secrecy::SecretString::from(
15890            "a-perfectly-adequate-cluster-secret".to_owned(),
15891        ));
15892
15893        // A wildcard bind with nothing to advertise: the bound address IS what
15894        // peers would be told to dial.
15895        config.cluster.bind_addr = "0.0.0.0:7946".to_owned();
15896        let result = config.cluster.validate();
15897        assert!(
15898            result.is_err(),
15899            "binding a wildcard with no advertise_addr must be refused: the node \
15900             would gossip 0.0.0.0 as its dial address, got {result:?}"
15901        );
15902        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15903        assert!(
15904            message.contains("cluster.bind_addr"),
15905            "the error must name the key the operator has to fix; got {message:?}"
15906        );
15907
15908        // An EXPLICIT wildcard advertise is the same mistake, spelled out.
15909        config.cluster.advertise_addr = Some("0.0.0.0:7946".to_owned());
15910        let result = config.cluster.validate();
15911        assert!(
15912            result.is_err(),
15913            "an explicit wildcard advertise_addr must be refused too, got {result:?}"
15914        );
15915        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15916        assert!(
15917            message.contains("cluster.advertise_addr"),
15918            "…and must blame advertise_addr, not the (legal) wildcard bind; \
15919             got {message:?}"
15920        );
15921
15922        // …while a wildcard bind WITH a concrete advertised address — the
15923        // documented container spelling — must pass, or the rule is a ban on
15924        // the very deployment shape the guide recommends.
15925        config.cluster.advertise_addr = Some("10.0.1.7:7946".to_owned());
15926        assert!(
15927            config.cluster.validate().is_ok(),
15928            "0.0.0.0 bind + explicit advertise_addr is the documented spelling \
15929             and must be accepted"
15930        );
15931
15932        // The rule is scoped to enabled sections: a disabled one binds nothing.
15933        config.cluster.enabled = false;
15934        config.cluster.advertise_addr = None;
15935        assert!(
15936            config.cluster.validate().is_ok(),
15937            "a disabled section advertises nothing, so the wildcard rule must \
15938             not fire on it"
15939        );
15940    }
15941
15942    /// `node_id` and `cluster_name` travel in every frame and are covered by
15943    /// the MAC, so the 64-byte cap keeps a push's fixed overhead predictable.
15944    #[test]
15945    fn cluster_rejects_over_long_idents() {
15946        let mut config = AutumnConfig::default();
15947        config.cluster.enabled = true;
15948        config.cluster.secret = Some(secrecy::SecretString::from(
15949            "a-perfectly-adequate-cluster-secret".to_owned(),
15950        ));
15951
15952        let at_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN);
15953        let over_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN.saturating_add(1));
15954
15955        config.cluster.node_id = Some(at_cap.clone());
15956        assert!(
15957            config.cluster.validate().is_ok(),
15958            "exactly {MAX_CLUSTER_IDENT_LEN} bytes must be accepted, or the cap \
15959             is off by one"
15960        );
15961
15962        config.cluster.node_id = Some(over_cap.clone());
15963        let result = config.cluster.validate();
15964        assert!(
15965            result.is_err(),
15966            "a node_id one byte over {MAX_CLUSTER_IDENT_LEN} must be refused, \
15967             got {result:?}"
15968        );
15969        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15970        assert!(
15971            message.contains("cluster.node_id"),
15972            "the error must name the offending key; got {message:?}"
15973        );
15974
15975        config.cluster.node_id = Some("node-a".to_owned());
15976        config.cluster.cluster_name = over_cap;
15977        let result = config.cluster.validate();
15978        assert!(
15979            result.is_err(),
15980            "an over-long cluster_name must be refused for the same reason, \
15981             got {result:?}"
15982        );
15983        let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15984        assert!(
15985            message.contains("cluster.cluster_name"),
15986            "…naming cluster_name this time; got {message:?}"
15987        );
15988
15989        config.cluster.cluster_name = at_cap;
15990        assert!(
15991            config.cluster.validate().is_ok(),
15992            "…and a cluster_name at the cap must be accepted, or the rule is vacuous"
15993        );
15994    }
15995
15996    #[test]
15997    fn shadow_env_overrides_apply() {
15998        let env = MockEnv::new()
15999            .with("AUTUMN_SHADOW__ENABLED", "true")
16000            .with("AUTUMN_SHADOW__TARGET", "http://127.0.0.1:9091")
16001            .with("AUTUMN_SHADOW__SAMPLE_RATE", "0.25")
16002            .with("AUTUMN_SHADOW__ROUTES", "/api/*, /status")
16003            .with("AUTUMN_SHADOW__TIMEOUT_MS", "750")
16004            .with("AUTUMN_SHADOW__MAX_IN_FLIGHT", "16")
16005            .with("AUTUMN_SHADOW__MAX_BODY_BYTES", "4096")
16006            .with("AUTUMN_SHADOW__MAX_RECORDS", "12")
16007            .with("AUTUMN_SHADOW__MAX_SAMPLE_BYTES", "256");
16008        let mut config = AutumnConfig::default();
16009        config.apply_env_overrides_with_env(&env);
16010
16011        assert!(config.shadow.enabled);
16012        assert_eq!(
16013            config.shadow.target.as_deref(),
16014            Some("http://127.0.0.1:9091")
16015        );
16016        assert!((config.shadow.sample_rate - 0.25).abs() < f64::EPSILON);
16017        assert_eq!(config.shadow.routes, vec!["/api/*", "/status"]);
16018        assert_eq!(config.shadow.timeout_ms, 750);
16019        assert_eq!(config.shadow.max_in_flight, 16);
16020        assert_eq!(config.shadow.max_body_bytes, 4096);
16021        assert_eq!(config.shadow.max_records, 12);
16022        assert_eq!(config.shadow.max_sample_bytes, 256);
16023        assert!(config.validate().is_ok());
16024    }
16025
16026    #[test]
16027    fn an_empty_shadow_routes_override_does_not_disable_mirroring() {
16028        // An unfilled compose/CI template (`AUTUMN_SHADOW__ROUTES=`) must not
16029        // become a one-element allowlist holding the empty pattern: that
16030        // matches no path, so the mirror would run and mirror nothing, with no
16031        // diagnostic. Same failure shape `parse_env_csv_non_empty` was added
16032        // for in #1621.
16033        let env = MockEnv::new().with("AUTUMN_SHADOW__ROUTES", "");
16034        let mut config = AutumnConfig::default();
16035        config.apply_env_overrides_with_env(&env);
16036        assert!(config.shadow.routes.is_empty());
16037    }
16038
16039    #[test]
16040    fn top_level_validation_rejects_an_unusable_shadow_section() {
16041        // Guards the `self.shadow.validate()?` call in `AutumnConfig::validate`:
16042        // without it a replica boots with mirroring "on" and no target.
16043        let mut config = AutumnConfig::default();
16044        config.shadow.enabled = true;
16045        let error = config.validate().expect_err("must reject");
16046        assert!(
16047            error.to_string().contains("shadow.target"),
16048            "the boot failure must name the missing key, got: {error}"
16049        );
16050    }
16051
16052    #[test]
16053    fn cluster_env_overrides_apply() {
16054        let env = MockEnv::new()
16055            .with("AUTUMN_CLUSTER__ENABLED", "true")
16056            .with("AUTUMN_CLUSTER__SECRET", "a-shared-cluster-secret-value")
16057            .with("AUTUMN_CLUSTER__CLUSTER_NAME", "orchard")
16058            .with("AUTUMN_CLUSTER__BIND_ADDR", "0.0.0.0:7946")
16059            .with("AUTUMN_CLUSTER__ADVERTISE_ADDR", "10.0.0.4:7946")
16060            .with("AUTUMN_CLUSTER__SEED_PEERS", "10.0.0.5:7946, 10.0.0.6:7946")
16061            .with("AUTUMN_CLUSTER__NODE_ID", "node-a")
16062            .with("AUTUMN_CLUSTER__PUSH_INTERVAL_MS", "250")
16063            .with("AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS", "1500");
16064        let mut config = AutumnConfig::default();
16065        config.apply_env_overrides_with_env(&env);
16066
16067        assert!(config.cluster.enabled);
16068        assert_eq!(
16069            config
16070                .cluster
16071                .secret
16072                .as_ref()
16073                .map(|s| secrecy::ExposeSecret::expose_secret(s).to_owned()),
16074            Some("a-shared-cluster-secret-value".to_owned())
16075        );
16076        assert_eq!(config.cluster.cluster_name, "orchard");
16077        assert_eq!(config.cluster.bind_addr, "0.0.0.0:7946");
16078        assert_eq!(
16079            config.cluster.advertise_addr.as_deref(),
16080            Some("10.0.0.4:7946")
16081        );
16082        assert_eq!(
16083            config.cluster.seed_peers,
16084            vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:7946".to_owned()],
16085            "AUTUMN_CLUSTER__SEED_PEERS must split on commas and trim"
16086        );
16087        assert_eq!(config.cluster.node_id.as_deref(), Some("node-a"));
16088        assert_eq!(config.cluster.push_interval_ms, 250);
16089        assert_eq!(config.cluster.suspicion_timeout_ms, 1_500);
16090    }
16091
16092    #[test]
16093    fn env_override_connect_timeout() {
16094        let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
16095        let mut config = AutumnConfig::default();
16096        config.apply_env_overrides_with_env(&env);
16097        assert_eq!(config.database.connect_timeout_secs, 15);
16098    }
16099
16100    #[test]
16101    fn env_override_read_your_writes() {
16102        let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "request");
16103        let mut config = AutumnConfig::default();
16104        config.apply_env_overrides_with_env(&env);
16105        assert_eq!(config.database.read_your_writes, ReadYourWrites::Request);
16106    }
16107
16108    #[test]
16109    fn env_override_read_your_writes_session() {
16110        let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "session");
16111        let mut config = AutumnConfig::default();
16112        config.apply_env_overrides_with_env(&env);
16113        assert_eq!(config.database.read_your_writes, ReadYourWrites::Session);
16114    }
16115
16116    #[test]
16117    fn env_override_pin_after_write_secs() {
16118        let env = MockEnv::new().with("AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS", "10");
16119        let mut config = AutumnConfig::default();
16120        config.apply_env_overrides_with_env(&env);
16121        assert_eq!(config.database.pin_after_write_secs, 10);
16122    }
16123
16124    #[test]
16125    fn replica_max_lag_and_pooler_warning_config() {
16126        let config = AutumnConfig::default();
16127        assert_eq!(config.database.replica_max_lag_ms, None);
16128        assert!(config.database.warn_on_pooler);
16129
16130        let parsed: AutumnConfig =
16131            toml::from_str("[database]\nreplica_max_lag_ms = 2500\nwarn_on_pooler = false\n")
16132                .unwrap();
16133        assert_eq!(parsed.database.replica_max_lag_ms, Some(2500));
16134        assert!(!parsed.database.warn_on_pooler);
16135
16136        let env = MockEnv::new()
16137            .with("AUTUMN_DATABASE__REPLICA_MAX_LAG_MS", "750")
16138            .with("AUTUMN_DATABASE__WARN_ON_POOLER", "false");
16139        let mut config = AutumnConfig::default();
16140        config.apply_env_overrides_with_env(&env);
16141        assert_eq!(config.database.replica_max_lag_ms, Some(750));
16142        assert!(!config.database.warn_on_pooler);
16143    }
16144
16145    #[test]
16146    fn env_override_invalid_pool_size_ignored() {
16147        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
16148        let mut config = AutumnConfig::default();
16149        config.apply_env_overrides_with_env(&env);
16150        assert_eq!(config.database.pool_size, 10);
16151    }
16152
16153    // ── auth.magic_link env overrides ─────────────────────────────────────────
16154
16155    #[test]
16156    fn env_override_magic_link_ttl_minutes() {
16157        let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45");
16158        let mut config = AutumnConfig::default();
16159        config.apply_env_overrides_with_env(&env);
16160        assert_eq!(config.auth.magic_link.ttl_minutes, 45);
16161    }
16162
16163    #[test]
16164    fn env_override_magic_link_email_cooldown_secs() {
16165        let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
16166        let mut config = AutumnConfig::default();
16167        config.apply_env_overrides_with_env(&env);
16168        assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
16169    }
16170
16171    #[test]
16172    fn env_override_magic_link_overrides_toml_value() {
16173        let env = MockEnv::new()
16174            .with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45")
16175            .with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
16176        let mut config = AutumnConfig::default();
16177        // Simulate values loaded from autumn.toml.
16178        config.auth.magic_link.ttl_minutes = 30;
16179        config.auth.magic_link.email_cooldown_secs = 200;
16180        config.apply_env_overrides_with_env(&env);
16181        assert_eq!(config.auth.magic_link.ttl_minutes, 45);
16182        assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
16183    }
16184
16185    #[test]
16186    fn env_unset_leaves_magic_link_toml_value_intact() {
16187        let env = MockEnv::new();
16188        let mut config = AutumnConfig::default();
16189        // Simulate values loaded from autumn.toml.
16190        config.auth.magic_link.ttl_minutes = 30;
16191        config.auth.magic_link.email_cooldown_secs = 200;
16192        config.apply_env_overrides_with_env(&env);
16193        assert_eq!(config.auth.magic_link.ttl_minutes, 30);
16194        assert_eq!(config.auth.magic_link.email_cooldown_secs, 200);
16195    }
16196
16197    #[test]
16198    fn env_override_invalid_magic_link_ttl_minutes_ignored() {
16199        let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "not_a_number");
16200        let mut config = AutumnConfig::default();
16201        // Simulate a value loaded from autumn.toml.
16202        config.auth.magic_link.ttl_minutes = 30;
16203        config.apply_env_overrides_with_env(&env);
16204        assert_eq!(config.auth.magic_link.ttl_minutes, 30);
16205    }
16206
16207    // ── startup_wait_secs ─────────────────────────────────────────────────────
16208
16209    #[test]
16210    fn startup_wait_secs_default_is_zero() {
16211        assert_eq!(DatabaseConfig::default().startup_wait_secs, 0);
16212    }
16213
16214    #[test]
16215    fn env_override_startup_wait_secs() {
16216        let env = MockEnv::new().with("AUTUMN_DATABASE__STARTUP_WAIT_SECS", "60");
16217        let mut config = AutumnConfig::default();
16218        config.apply_env_overrides_with_env(&env);
16219        assert_eq!(config.database.startup_wait_secs, 60);
16220    }
16221
16222    #[test]
16223    fn startup_wait_secs_parses_from_toml() {
16224        let config: AutumnConfig = toml::from_str("[database]\nstartup_wait_secs = 30").unwrap();
16225        assert_eq!(config.database.startup_wait_secs, 30);
16226    }
16227
16228    #[cfg(feature = "storage")]
16229    #[test]
16230    fn env_override_storage_fields() {
16231        let env = MockEnv::new()
16232            .with("AUTUMN_STORAGE__BACKEND", "s3")
16233            .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
16234            .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
16235            .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
16236            .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
16237            .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
16238            .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
16239            .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
16240            .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
16241            .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
16242            .with(
16243                "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
16244                "https://cdn.example.test",
16245            )
16246            .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
16247            .with(
16248                "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
16249                "AWS_SECRET_ACCESS_KEY",
16250            )
16251            .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
16252            .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
16253            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
16254            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
16255            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
16256        let mut config = AutumnConfig::default();
16257
16258        config.apply_env_overrides_with_env(&env);
16259
16260        assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
16261        assert_eq!(config.storage.default_provider, "media");
16262        assert!(config.storage.allow_local_in_production);
16263        assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
16264        assert_eq!(config.storage.local.mount_path, "/files");
16265        assert_eq!(config.storage.local.default_url_expiry_secs, 42);
16266        assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
16267        assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
16268        assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
16269        assert_eq!(
16270            config.storage.s3.endpoint.as_deref(),
16271            Some("https://s3.example.test")
16272        );
16273        assert_eq!(
16274            config.storage.s3.public_base_url.as_deref(),
16275            Some("https://cdn.example.test")
16276        );
16277        assert_eq!(
16278            config.storage.s3.access_key_id_env.as_deref(),
16279            Some("AWS_ACCESS_KEY_ID")
16280        );
16281        assert_eq!(
16282            config.storage.s3.secret_access_key_env.as_deref(),
16283            Some("AWS_SECRET_ACCESS_KEY")
16284        );
16285        assert!(config.storage.s3.force_path_style);
16286        assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
16287        assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
16288        assert_eq!(config.storage.variants.max_source_width, 2_000);
16289        assert_eq!(config.storage.variants.max_source_height, 1_500);
16290    }
16291
16292    // ── [replication] (issue #1628) ──────────────────────────────────────────
16293
16294    #[test]
16295    fn replication_parses_from_toml() {
16296        let toml = r#"
16297            [replication]
16298            enabled = true
16299            rpo_secs = 5
16300            sync_interval_secs = 2
16301            snapshot_interval_secs = 900
16302            max_wal_bytes = 8388608
16303            retention_hours = 48
16304            verify_interval_secs = 3600
16305            prefix = "db"
16306            allow_shared_bucket = true
16307
16308            [replication.s3]
16309            bucket = "myapp-replicas"
16310            region = "auto"
16311            endpoint = "https://acct.r2.cloudflarestorage.com"
16312            access_key_id_env = "AUTUMN_REPLICA_ACCESS_KEY_ID"
16313            secret_access_key_env = "AUTUMN_REPLICA_SECRET_ACCESS_KEY"
16314            force_path_style = true
16315        "#;
16316        let config: AutumnConfig = toml::from_str(toml).expect("parse");
16317        let replication = *config.replication.expect("section present");
16318        assert!(replication.enabled);
16319        assert_eq!(replication.rpo_secs, 5);
16320        assert_eq!(
16321            replication.sync_interval(),
16322            std::time::Duration::from_secs(2)
16323        );
16324        assert_eq!(replication.snapshot_interval_secs, 900);
16325        assert_eq!(replication.max_wal_bytes, 8_388_608);
16326        assert_eq!(replication.retention_hours, 48);
16327        assert_eq!(
16328            replication.verify_interval(),
16329            Some(std::time::Duration::from_secs(3600))
16330        );
16331        assert_eq!(replication.prefix.as_deref(), Some("db"));
16332        assert!(replication.allow_shared_bucket);
16333        let s3 = replication.s3.expect("s3 destination");
16334        assert_eq!(s3.bucket.as_deref(), Some("myapp-replicas"));
16335        assert_eq!(s3.region.as_deref(), Some("auto"));
16336        assert_eq!(
16337            s3.endpoint.as_deref(),
16338            Some("https://acct.r2.cloudflarestorage.com")
16339        );
16340        assert_eq!(
16341            s3.access_key_id_env.as_deref(),
16342            Some("AUTUMN_REPLICA_ACCESS_KEY_ID")
16343        );
16344        assert_eq!(
16345            s3.secret_access_key_env.as_deref(),
16346            Some("AUTUMN_REPLICA_SECRET_ACCESS_KEY")
16347        );
16348        assert!(s3.force_path_style);
16349    }
16350
16351    #[test]
16352    fn replication_defaults_to_absent_and_carries_the_documented_rpo() {
16353        let config = AutumnConfig::default();
16354        assert!(config.replication.is_none());
16355
16356        let defaults = ReplicationConfig::default();
16357        assert!(!defaults.enabled, "replication is opt-in");
16358        assert_eq!(defaults.rpo_secs, 10, "AC #2: at most 10s of data loss");
16359        assert_eq!(defaults.retention_hours, 168);
16360        assert_eq!(defaults.max_wal_bytes, 16 * 1024 * 1024);
16361        assert!(
16362            !defaults.allow_shared_bucket,
16363            "#1619's distinct-bucket posture"
16364        );
16365        assert!(defaults.validation_errors().is_empty(), "disabled is valid");
16366    }
16367
16368    #[test]
16369    fn replication_validation_names_every_problem() {
16370        let no_destination = ReplicationConfig {
16371            enabled: true,
16372            ..ReplicationConfig::default()
16373        };
16374        assert!(
16375            no_destination
16376                .validation_errors()
16377                .iter()
16378                .any(|e| e.contains("no destination is configured")),
16379            "{:?}",
16380            no_destination.validation_errors()
16381        );
16382
16383        let both = ReplicationConfig {
16384            enabled: true,
16385            path: Some("/mnt/replica".to_owned()),
16386            s3: Some(ReplicationS3Config {
16387                bucket: Some("b".to_owned()),
16388                ..ReplicationS3Config::default()
16389            }),
16390            ..ReplicationConfig::default()
16391        };
16392        assert!(
16393            both.validation_errors()
16394                .iter()
16395                .any(|e| e.contains("pick exactly one destination"))
16396        );
16397
16398        let no_credentials = ReplicationConfig {
16399            enabled: true,
16400            s3: Some(ReplicationS3Config {
16401                bucket: Some("b".to_owned()),
16402                ..ReplicationS3Config::default()
16403            }),
16404            rpo_secs: 0,
16405            retention_hours: 0,
16406            ..ReplicationConfig::default()
16407        };
16408        let errors = no_credentials.validation_errors();
16409        assert!(
16410            errors.iter().any(|e| e.contains("access_key_id_env")),
16411            "{errors:?}"
16412        );
16413        assert!(errors.iter().any(|e| e.contains("rpo_secs")), "{errors:?}");
16414        assert!(
16415            errors.iter().any(|e| e.contains("retention_hours")),
16416            "{errors:?}"
16417        );
16418    }
16419
16420    #[test]
16421    fn env_override_replication_fields() {
16422        let env = MockEnv::new()
16423            .with("AUTUMN_REPLICATION__ENABLED", "true")
16424            .with("AUTUMN_REPLICATION__RPO_SECS", "20")
16425            .with("AUTUMN_REPLICATION__SYNC_INTERVAL_SECS", "7")
16426            .with("AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS", "600")
16427            .with("AUTUMN_REPLICATION__MAX_WAL_BYTES", "1048576")
16428            .with("AUTUMN_REPLICATION__RETENTION_HOURS", "12")
16429            .with("AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS", "0")
16430            .with("AUTUMN_REPLICATION__PREFIX", "replicas")
16431            .with("AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET", "true")
16432            .with("AUTUMN_REPLICATION__S3__BUCKET", "from-env")
16433            .with("AUTUMN_REPLICATION__S3__REGION", "auto")
16434            .with(
16435                "AUTUMN_REPLICATION__S3__ENDPOINT",
16436                "https://minio.test:9000",
16437            )
16438            .with("AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV", "KEY")
16439            .with("AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV", "SECRET")
16440            .with("AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE", "true");
16441        let mut config = AutumnConfig::default();
16442        config.apply_env_overrides_with_env(&env);
16443
16444        let replication = *config.replication.expect("materialized from env alone");
16445        assert!(replication.enabled);
16446        assert_eq!(replication.rpo_secs, 20);
16447        assert_eq!(replication.sync_interval_secs, Some(7));
16448        assert_eq!(replication.snapshot_interval_secs, 600);
16449        assert_eq!(replication.max_wal_bytes, 1_048_576);
16450        assert_eq!(replication.retention_hours, 12);
16451        assert_eq!(replication.verify_interval(), None);
16452        assert_eq!(replication.prefix.as_deref(), Some("replicas"));
16453        assert!(replication.allow_shared_bucket);
16454        let s3 = replication.s3.expect("s3 from env");
16455        assert_eq!(s3.bucket.as_deref(), Some("from-env"));
16456        assert_eq!(s3.endpoint.as_deref(), Some("https://minio.test:9000"));
16457        assert!(s3.force_path_style);
16458    }
16459
16460    #[test]
16461    fn replication_env_does_not_materialize_a_section_from_optional_keys_alone() {
16462        // A lone region/prefix cannot replicate anywhere, so it must NOT conjure
16463        // a section that then fails validation (mirrors #1619's #1791 fix).
16464        let env = MockEnv::new()
16465            .with("AUTUMN_REPLICATION__S3__REGION", "auto")
16466            .with("AUTUMN_REPLICATION__PREFIX", "db")
16467            .with("AUTUMN_REPLICATION__ENABLED", "false");
16468        let mut config = AutumnConfig::default();
16469        config.apply_env_overrides_with_env(&env);
16470        assert!(config.replication.is_none());
16471    }
16472
16473    #[test]
16474    fn a_path_destination_from_env_never_grows_an_empty_s3_section() {
16475        let env = MockEnv::new()
16476            .with("AUTUMN_REPLICATION__ENABLED", "true")
16477            .with("AUTUMN_REPLICATION__PATH", "/mnt/replica");
16478        let mut config = AutumnConfig::default();
16479        config.apply_env_overrides_with_env(&env);
16480        let replication = *config.replication.expect("materialized");
16481        assert_eq!(replication.path.as_deref(), Some("/mnt/replica"));
16482        assert!(replication.s3.is_none(), "no S3 key was set");
16483        assert!(replication.validation_errors().is_empty());
16484    }
16485
16486    #[test]
16487    fn replication_env_overlays_a_toml_section() {
16488        let mut config: AutumnConfig = toml::from_str(
16489            "[replication]\nenabled = false\nretention_hours = 24\npath = \"/from-toml\"",
16490        )
16491        .expect("parse");
16492        let env = MockEnv::new().with("AUTUMN_REPLICATION__ENABLED", "true");
16493        config.apply_env_overrides_with_env(&env);
16494        let replication = *config.replication.expect("section");
16495        assert!(replication.enabled, "env flips the TOML toggle");
16496        assert_eq!(replication.retention_hours, 24, "TOML value survives");
16497        assert_eq!(replication.path.as_deref(), Some("/from-toml"));
16498    }
16499
16500    #[test]
16501    fn backup_offsite_parses_from_toml() {
16502        let toml = r#"
16503            [backup.offsite]
16504            prefix = "db"
16505            keep = 5
16506            auto_upload = true
16507            allow_shared_bucket = true
16508
16509            [backup.offsite.s3]
16510            bucket = "offsite-backups"
16511            region = "auto"
16512            endpoint = "https://minio.example.test"
16513            access_key_id_env = "OFFSITE_KEY_ID"
16514            secret_access_key_env = "OFFSITE_SECRET"
16515            force_path_style = true
16516        "#;
16517        let config: AutumnConfig = toml::from_str(toml).unwrap();
16518        let offsite = config.backup.offsite.expect("offsite section present");
16519        assert_eq!(offsite.prefix.as_deref(), Some("db"));
16520        assert_eq!(offsite.keep, Some(5));
16521        assert!(offsite.auto_upload);
16522        assert!(offsite.allow_shared_bucket);
16523        assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite-backups"));
16524        assert_eq!(offsite.s3.region.as_deref(), Some("auto"));
16525        assert_eq!(
16526            offsite.s3.endpoint.as_deref(),
16527            Some("https://minio.example.test")
16528        );
16529        // Credentials are indirected: config names the env vars, never the values.
16530        assert_eq!(
16531            offsite.s3.access_key_id_env.as_deref(),
16532            Some("OFFSITE_KEY_ID")
16533        );
16534        assert_eq!(
16535            offsite.s3.secret_access_key_env.as_deref(),
16536            Some("OFFSITE_SECRET")
16537        );
16538        assert!(offsite.s3.force_path_style);
16539    }
16540
16541    #[test]
16542    fn backup_offsite_defaults_to_none() {
16543        let config = AutumnConfig::default();
16544        assert!(config.backup.offsite.is_none());
16545    }
16546
16547    #[test]
16548    fn env_override_backup_offsite_fields() {
16549        let env = MockEnv::new()
16550            .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16551            .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
16552            .with(
16553                "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
16554                "https://s3.offsite.test",
16555            )
16556            .with("AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV", "OFF_KEY")
16557            .with(
16558                "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
16559                "OFF_SECRET",
16560            )
16561            .with("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true")
16562            .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
16563            .with("AUTUMN_BACKUP__OFFSITE__KEEP", "3")
16564            .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true")
16565            .with("AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET", "true");
16566        let mut config = AutumnConfig::default();
16567
16568        config.apply_env_overrides_with_env(&env);
16569
16570        let offsite = config.backup.offsite.expect("materialized from env");
16571        assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16572        assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
16573        assert_eq!(
16574            offsite.s3.endpoint.as_deref(),
16575            Some("https://s3.offsite.test")
16576        );
16577        assert_eq!(offsite.s3.access_key_id_env.as_deref(), Some("OFF_KEY"));
16578        assert_eq!(
16579            offsite.s3.secret_access_key_env.as_deref(),
16580            Some("OFF_SECRET")
16581        );
16582        assert!(offsite.s3.force_path_style);
16583        assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
16584        assert_eq!(offsite.keep, Some(3));
16585        assert!(offsite.auto_upload);
16586        assert!(offsite.allow_shared_bucket);
16587    }
16588
16589    #[test]
16590    fn env_override_backup_offsite_absent_stays_none() {
16591        // With no offsite env vars and no TOML section, nothing is materialized.
16592        let env = MockEnv::new();
16593        let mut config = AutumnConfig::default();
16594        config.apply_env_overrides_with_env(&env);
16595        assert!(config.backup.offsite.is_none());
16596    }
16597
16598    #[test]
16599    fn env_override_backup_offsite_lone_opt_out_toggle_stays_none() {
16600        // P2 #18: a lone false/opt-out toggle must NOT materialize an empty
16601        // [backup.offsite] (which would then fail validation / `doctor` with
16602        // "bucket is unset"). Offsite stays unconfigured.
16603        for key in [
16604            "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
16605            "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
16606        ] {
16607            let env = MockEnv::new().with(key, "false");
16608            let mut config = AutumnConfig::default();
16609            config.apply_env_overrides_with_env(&env);
16610            assert!(
16611                config.backup.offsite.is_none(),
16612                "{key}=false must not materialize an offsite section",
16613            );
16614        }
16615    }
16616
16617    #[test]
16618    fn env_override_backup_offsite_truthy_auto_upload_materializes() {
16619        // P2 #18: AUTO_UPLOAD=true genuinely needs a validated destination, so it
16620        // DOES materialize the section (auto_upload set), as before.
16621        let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true");
16622        let mut config = AutumnConfig::default();
16623        config.apply_env_overrides_with_env(&env);
16624        let offsite = config
16625            .backup
16626            .offsite
16627            .expect("auto_upload=true materializes offsite");
16628        assert!(offsite.auto_upload);
16629    }
16630
16631    #[test]
16632    fn env_override_backup_offsite_destination_key_materializes() {
16633        // A destination/credential key still materializes the section (with the
16634        // opt-out toggle applied to it), unchanged from before P2 #18.
16635        let env = MockEnv::new()
16636            .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16637            .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "false");
16638        let mut config = AutumnConfig::default();
16639        config.apply_env_overrides_with_env(&env);
16640        let offsite = config
16641            .backup
16642            .offsite
16643            .expect("a bucket key materializes offsite");
16644        assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16645        assert!(!offsite.auto_upload);
16646    }
16647
16648    #[test]
16649    fn env_override_backup_offsite_lone_optional_key_stays_none() {
16650        // #1791: optional-only keys (region, force_path_style, endpoint, prefix,
16651        // keep) must NOT materialize [backup.offsite] on their own — a bare
16652        // region with no bucket/credentials cannot upload, so offsite stays
16653        // UNCONFIGURED rather than producing an empty section that then fails
16654        // `doctor` with "bucket is unset".
16655        for (key, val) in [
16656            ("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-east-1"),
16657            ("AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT", "https://s3.test"),
16658            ("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true"),
16659            ("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly"),
16660            ("AUTUMN_BACKUP__OFFSITE__KEEP", "3"),
16661        ] {
16662            let env = MockEnv::new().with(key, val);
16663            let mut config = AutumnConfig::default();
16664            config.apply_env_overrides_with_env(&env);
16665            assert!(
16666                config.backup.offsite.is_none(),
16667                "{key} is optional-only and must not materialize an offsite section",
16668            );
16669        }
16670    }
16671
16672    #[test]
16673    fn env_override_backup_offsite_credential_key_materializes() {
16674        // #1791: the access/secret key-env names are REQUIRED signals, so either
16675        // one still materializes the section.
16676        for key in [
16677            "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
16678            "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
16679        ] {
16680            let env = MockEnv::new().with(key, "SOME_ENV_NAME");
16681            let mut config = AutumnConfig::default();
16682            config.apply_env_overrides_with_env(&env);
16683            assert!(
16684                config.backup.offsite.is_some(),
16685                "{key} is a required credential signal and must materialize offsite",
16686            );
16687        }
16688    }
16689
16690    #[test]
16691    fn env_override_backup_offsite_bucket_only_materializes() {
16692        // #1791: a lone bucket (required destination signal) still materializes.
16693        let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite");
16694        let mut config = AutumnConfig::default();
16695        config.apply_env_overrides_with_env(&env);
16696        let offsite = config
16697            .backup
16698            .offsite
16699            .expect("a bucket key materializes offsite");
16700        assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16701    }
16702
16703    #[test]
16704    fn env_override_backup_offsite_region_only_applied_when_materialized() {
16705        // #1791: region no longer TRIGGERS materialization, but it is still
16706        // APPLIED when a required key materializes the section.
16707        let env = MockEnv::new()
16708            .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16709            .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
16710            .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
16711            .with("AUTUMN_BACKUP__OFFSITE__KEEP", "5");
16712        let mut config = AutumnConfig::default();
16713        config.apply_env_overrides_with_env(&env);
16714        let offsite = config.backup.offsite.expect("bucket materializes offsite");
16715        assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
16716        assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
16717        assert_eq!(offsite.keep, Some(5));
16718    }
16719
16720    #[test]
16721    fn env_override_database_auto_migrate_in_production() {
16722        let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
16723        let mut config = AutumnConfig::default();
16724        config.apply_env_overrides_with_env(&env);
16725        assert!(config.database.auto_migrate_in_production);
16726    }
16727
16728    #[test]
16729    fn database_auto_migrate_defaults_to_none() {
16730        // Issue #1903: the profile-agnostic override is unset by default, so the
16731        // decision falls to convention (dev on, everything else opt-in).
16732        assert_eq!(DatabaseConfig::default().auto_migrate, None);
16733    }
16734
16735    #[test]
16736    fn env_override_database_auto_migrate() {
16737        // Issue #1903: AUTUMN_DATABASE__AUTO_MIGRATE flips the profile-agnostic
16738        // override to an explicit Some(_) on any profile.
16739        let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "true");
16740        let mut config = AutumnConfig::default();
16741        assert_eq!(config.database.auto_migrate, None);
16742        config.apply_env_overrides_with_env(&env);
16743        assert_eq!(config.database.auto_migrate, Some(true));
16744
16745        let env_false = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "false");
16746        let mut config_false = AutumnConfig::default();
16747        config_false.apply_env_overrides_with_env(&env_false);
16748        assert_eq!(config_false.database.auto_migrate, Some(false));
16749    }
16750
16751    #[test]
16752    fn env_override_jobs_fields() {
16753        let env = MockEnv::new()
16754            .with("AUTUMN_JOBS__BACKEND", "redis")
16755            .with("AUTUMN_JOBS__WORKERS", "8")
16756            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
16757            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
16758            .with("AUTUMN_JOBS__MAX_BACKOFF_MS", "90000")
16759            .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
16760            .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
16761            .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
16762        let mut config = AutumnConfig::default();
16763        config.apply_env_overrides_with_env(&env);
16764
16765        assert_eq!(config.jobs.backend, "redis");
16766        assert_eq!(config.jobs.workers, 8);
16767        assert_eq!(config.jobs.max_attempts, 12);
16768        assert_eq!(config.jobs.initial_backoff_ms, 750);
16769        assert_eq!(config.jobs.max_backoff_ms, 90_000);
16770        assert_eq!(
16771            config.jobs.redis.url.as_deref(),
16772            Some("redis://jobs:6379/2")
16773        );
16774        assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
16775        assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
16776    }
16777
16778    /// `jobs.default_timeout_ms` defaults to `0` (no limit) and reads from
16779    /// TOML and the environment (issue #3051).
16780    #[test]
16781    fn jobs_default_timeout_ms_defaults_to_zero_and_overrides() {
16782        assert_eq!(AutumnConfig::default().jobs.default_timeout_ms, 0);
16783
16784        let config: AutumnConfig = toml::from_str("[jobs]\ndefault_timeout_ms = 30000\n").unwrap();
16785        assert_eq!(config.jobs.default_timeout_ms, 30_000);
16786
16787        let env = MockEnv::new().with("AUTUMN_JOBS__DEFAULT_TIMEOUT_MS", "1500");
16788        let mut config = AutumnConfig::default();
16789        config.apply_env_overrides_with_env(&env);
16790        assert_eq!(config.jobs.default_timeout_ms, 1_500);
16791    }
16792
16793    // ── [retention] unified framework-owned data retention (issue #1605) ──
16794
16795    #[test]
16796    fn retention_config_defaults_leave_every_dataset_unset() {
16797        // AC #1: "Leaving a dataset unset preserves today's behavior exactly."
16798        // The only way to guarantee that is for every window to default to
16799        // None, and for the whole section to report itself as unconfigured.
16800        let config = AutumnConfig::default();
16801        assert!(
16802            config.retention.job_history.is_none(),
16803            "job_history must default to unset"
16804        );
16805        assert!(config.retention.job_tracking.is_none());
16806        assert!(config.retention.idempotency.is_none());
16807        assert!(config.retention.experiment_assignments.is_none());
16808        assert!(config.retention.webhook_replay.is_none());
16809        assert!(config.retention.sessions.is_none());
16810        assert!(config.retention.audit_archives.is_none());
16811        assert!(
16812            !config.retention.any_window_configured(),
16813            "a default config must register no retention sweep at all"
16814        );
16815        assert_eq!(
16816            config.retention.sweep_interval, "1h",
16817            "the sweep cadence has a documented default even when no window is set"
16818        );
16819    }
16820
16821    #[test]
16822    fn retention_toml_deserializes_every_dataset_window() {
16823        let config: AutumnConfig = toml::from_str(
16824            r#"
16825            [retention]
16826            sweep_interval = "30m"
16827            job_history = "90d"
16828            job_tracking = "7d"
16829            idempotency = "2d"
16830            experiment_assignments = "365d"
16831            webhook_replay = "3d"
16832            sessions = "30d"
16833            audit_archives = "400d"
16834            "#,
16835        )
16836        .unwrap();
16837
16838        assert_eq!(config.retention.sweep_interval, "30m");
16839        assert_eq!(config.retention.job_history.as_deref(), Some("90d"));
16840        assert_eq!(config.retention.job_tracking.as_deref(), Some("7d"));
16841        assert_eq!(config.retention.idempotency.as_deref(), Some("2d"));
16842        assert_eq!(
16843            config.retention.experiment_assignments.as_deref(),
16844            Some("365d")
16845        );
16846        assert_eq!(config.retention.webhook_replay.as_deref(), Some("3d"));
16847        assert_eq!(config.retention.sessions.as_deref(), Some("30d"));
16848        assert_eq!(config.retention.audit_archives.as_deref(), Some("400d"));
16849        assert!(config.retention.any_window_configured());
16850        config.validate().expect("a well-formed section validates");
16851    }
16852
16853    #[test]
16854    fn env_override_retention_windows() {
16855        let env = MockEnv::new()
16856            .with("AUTUMN_RETENTION__SWEEP_INTERVAL", "15m")
16857            .with("AUTUMN_RETENTION__JOB_HISTORY", "45d")
16858            .with("AUTUMN_RETENTION__JOB_TRACKING", "2d")
16859            .with("AUTUMN_RETENTION__IDEMPOTENCY", "12h")
16860            .with("AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS", "180d")
16861            .with("AUTUMN_RETENTION__WEBHOOK_REPLAY", "1d")
16862            .with("AUTUMN_RETENTION__SESSIONS", "14d")
16863            .with("AUTUMN_RETENTION__AUDIT_ARCHIVES", "365d")
16864            .with("AUTUMN_RETENTION__CUSTOM_DOMAINS", "30d");
16865        let mut config = AutumnConfig::default();
16866        config.apply_env_overrides_with_env(&env);
16867
16868        assert_eq!(config.retention.sweep_interval, "15m");
16869        assert_eq!(config.retention.job_history.as_deref(), Some("45d"));
16870        assert_eq!(config.retention.job_tracking.as_deref(), Some("2d"));
16871        assert_eq!(config.retention.idempotency.as_deref(), Some("12h"));
16872        assert_eq!(
16873            config.retention.experiment_assignments.as_deref(),
16874            Some("180d")
16875        );
16876        assert_eq!(config.retention.webhook_replay.as_deref(), Some("1d"));
16877        assert_eq!(config.retention.sessions.as_deref(), Some("14d"));
16878        assert_eq!(config.retention.audit_archives.as_deref(), Some("365d"));
16879        assert_eq!(config.retention.custom_domains.as_deref(), Some("30d"));
16880    }
16881
16882    #[test]
16883    fn env_override_retention_empty_value_clears_a_window() {
16884        // An empty env value is the documented way to *unset* a window that
16885        // autumn.toml declared, restoring today's behavior for that dataset
16886        // without editing the file (matches parse_env_option_string).
16887        let env = MockEnv::new().with("AUTUMN_RETENTION__JOB_HISTORY", "");
16888        let mut config = AutumnConfig::default();
16889        config.retention.job_history = Some("90d".to_owned());
16890        config.apply_env_overrides_with_env(&env);
16891
16892        assert!(config.retention.job_history.is_none());
16893    }
16894
16895    #[test]
16896    fn retention_validate_rejects_an_unparseable_window() {
16897        let mut config = AutumnConfig::default();
16898        config.retention.job_history = Some("ninety days".to_owned());
16899        let error = config
16900            .validate()
16901            .expect_err("an unparseable duration must fail boot, not be ignored");
16902        let message = error.to_string();
16903        assert!(
16904            message.contains("retention.job_history"),
16905            "the error must name the offending key: {message}"
16906        );
16907    }
16908
16909    #[test]
16910    fn retention_validate_rejects_a_zero_window() {
16911        // "0s" would purge everything the instant it is written — almost
16912        // certainly a typo, and never something to guess at.
16913        let mut config = AutumnConfig::default();
16914        config.retention.sessions = Some("0s".to_owned());
16915        let error = config.validate().expect_err("a zero window must fail boot");
16916        assert!(error.to_string().contains("retention.sessions"), "{error}");
16917    }
16918
16919    #[test]
16920    fn retention_validate_rejects_an_unparseable_sweep_interval() {
16921        let mut config = AutumnConfig::default();
16922        config.retention.job_history = Some("30d".to_owned());
16923        config.retention.sweep_interval = "whenever".to_owned();
16924        let error = config.validate().expect_err("bad cadence must fail boot");
16925        assert!(
16926            error.to_string().contains("retention.sweep_interval"),
16927            "{error}"
16928        );
16929    }
16930
16931    #[test]
16932    fn retention_validate_accepts_a_default_config() {
16933        // The whole point of AC #1: an app that never mentions [retention]
16934        // must be entirely unaffected, validation included.
16935        AutumnConfig::default()
16936            .validate()
16937            .expect("an unconfigured [retention] section must validate");
16938    }
16939
16940    #[test]
16941    fn job_tracking_config_defaults_ttl_86400_and_route_enabled() {
16942        let config = AutumnConfig::default();
16943        assert_eq!(config.jobs.tracking.ttl_secs, 86_400);
16944        assert!(config.jobs.tracking.route_enabled);
16945    }
16946
16947    #[test]
16948    fn env_override_jobs_tracking_fields() {
16949        let env = MockEnv::new()
16950            .with("AUTUMN_JOBS__TRACKING__TTL_SECS", "3600")
16951            .with("AUTUMN_JOBS__TRACKING__ROUTE_ENABLED", "false");
16952        let mut config = AutumnConfig::default();
16953        config.apply_env_overrides_with_env(&env);
16954
16955        assert_eq!(config.jobs.tracking.ttl_secs, 3_600);
16956        assert!(!config.jobs.tracking.route_enabled);
16957    }
16958
16959    #[test]
16960    fn jobs_toml_deserializes_tracking_fields() {
16961        let config: AutumnConfig = toml::from_str(
16962            r"
16963            [jobs.tracking]
16964            ttl_secs = 7200
16965            route_enabled = false
16966            ",
16967        )
16968        .unwrap();
16969
16970        assert_eq!(config.jobs.tracking.ttl_secs, 7_200);
16971        assert!(!config.jobs.tracking.route_enabled);
16972    }
16973
16974    #[test]
16975    fn jobs_toml_deserializes_redis_visibility_timeout() {
16976        let config: AutumnConfig = toml::from_str(
16977            r#"
16978            [jobs]
16979            backend = "redis"
16980
16981            [jobs.redis]
16982            url = "redis://localhost:6379/5"
16983            key_prefix = "demo:jobs"
16984            visibility_timeout_ms = 15000
16985            "#,
16986        )
16987        .unwrap();
16988
16989        assert_eq!(config.jobs.backend, "redis");
16990        assert_eq!(
16991            config.jobs.redis.url.as_deref(),
16992            Some("redis://localhost:6379/5")
16993        );
16994        assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
16995        assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
16996    }
16997
16998    /// Issue #3055: the dead-letter cap is configurable, and `0` is unbounded.
16999    #[test]
17000    fn jobs_redis_dead_letter_limit_defaults_parses_and_overrides() {
17001        // The default is not smaller than the old hard-coded 1,000.
17002        let defaults = JobRedisConfig::default();
17003        assert_eq!(defaults.dead_letter_limit, 10_000);
17004        assert_eq!(AutumnConfig::default().jobs.redis.dead_letter_limit, 10_000);
17005
17006        let config: AutumnConfig =
17007            toml::from_str("[jobs.redis]\ndead_letter_limit = 0\n").expect("dead_letter_limit");
17008        assert_eq!(config.jobs.redis.dead_letter_limit, 0);
17009
17010        let config: AutumnConfig = toml::from_str("[jobs.redis]\nkey_prefix = \"x\"\n").unwrap();
17011        assert_eq!(config.jobs.redis.dead_letter_limit, 10_000);
17012
17013        let env = MockEnv::new().with("AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT", "250");
17014        let mut config = AutumnConfig::default();
17015        config.apply_env_overrides_with_env(&env);
17016        assert_eq!(config.jobs.redis.dead_letter_limit, 250);
17017    }
17018
17019    #[test]
17020    fn outbox_validate_rejects_zero_values() {
17021        assert!(OutboxConfig::default().validate().is_ok());
17022        for config in [
17023            OutboxConfig {
17024                lease_ms: 0,
17025                ..OutboxConfig::default()
17026            },
17027            OutboxConfig {
17028                max_attempts: 0,
17029                ..OutboxConfig::default()
17030            },
17031            OutboxConfig {
17032                batch_size: 0,
17033                ..OutboxConfig::default()
17034            },
17035        ] {
17036            let error = config.validate().unwrap_err().to_string();
17037            assert!(error.contains("must be greater than zero"), "{error}");
17038        }
17039        let error = OutboxConfig {
17040            initial_backoff_ms: 300_000,
17041            max_backoff_ms: 1_000,
17042            ..OutboxConfig::default()
17043        }
17044        .validate()
17045        .unwrap_err()
17046        .to_string();
17047        assert!(error.contains("max_backoff_ms"), "{error}");
17048    }
17049
17050    #[test]
17051    fn outbox_defaults_parse_and_env_overrides() {
17052        let defaults = AutumnConfig::default().outbox;
17053        assert!(!defaults.enabled, "the outbox is off by default");
17054        assert_eq!(defaults, OutboxConfig::default());
17055        assert_eq!(defaults.max_attempts, 10);
17056
17057        let config: AutumnConfig =
17058            toml::from_str("[outbox]\nenabled = true\nlease_ms = 5000\n").expect("outbox");
17059        assert!(config.outbox.enabled);
17060        assert_eq!(config.outbox.lease_ms, 5_000);
17061        assert_eq!(config.outbox.batch_size, 100, "unset keys keep defaults");
17062
17063        let env = MockEnv::new()
17064            .with("AUTUMN_OUTBOX__ENABLED", "true")
17065            .with("AUTUMN_OUTBOX__POLL_INTERVAL_MS", "50")
17066            .with("AUTUMN_OUTBOX__BATCH_SIZE", "7")
17067            .with("AUTUMN_OUTBOX__MAX_ATTEMPTS", "3")
17068            .with("AUTUMN_OUTBOX__INITIAL_BACKOFF_MS", "20")
17069            .with("AUTUMN_OUTBOX__MAX_BACKOFF_MS", "200")
17070            .with("AUTUMN_OUTBOX__LEASE_MS", "900")
17071            .with("AUTUMN_OUTBOX__RETENTION_MS", "1000");
17072        let mut config = AutumnConfig::default();
17073        config.apply_env_overrides_with_env(&env);
17074        assert_eq!(
17075            config.outbox,
17076            OutboxConfig {
17077                enabled: true,
17078                poll_interval_ms: 50,
17079                batch_size: 7,
17080                max_attempts: 3,
17081                initial_backoff_ms: 20,
17082                max_backoff_ms: 200,
17083                lease_ms: 900,
17084                retention_ms: 1_000,
17085            }
17086        );
17087    }
17088
17089    #[test]
17090    fn job_queues_defaults_to_single_default_queue() {
17091        let config = AutumnConfig::default();
17092        assert!(config.jobs.queues.strict);
17093        assert_eq!(config.jobs.queues.queues.len(), 1);
17094        assert_eq!(config.jobs.queues.queues[0].name, "default");
17095        assert_eq!(config.jobs.queues.queues[0].weight, 1);
17096    }
17097
17098    #[test]
17099    fn jobs_without_queues_key_keeps_single_default_queue() {
17100        let config: AutumnConfig = toml::from_str(
17101            r#"
17102            [jobs]
17103            backend = "local"
17104            workers = 4
17105            "#,
17106        )
17107        .unwrap();
17108        assert!(config.jobs.queues.strict);
17109        assert_eq!(config.jobs.queues.queues.len(), 1);
17110        assert_eq!(config.jobs.queues.queues[0].name, "default");
17111    }
17112
17113    #[test]
17114    fn job_queues_parse_ordered_list_as_strict_priority() {
17115        let config: AutumnConfig = toml::from_str(
17116            r#"
17117            [jobs]
17118            backend = "local"
17119            queues = ["critical", "default", "low"]
17120            "#,
17121        )
17122        .unwrap();
17123        assert!(config.jobs.queues.strict, "list form is strict priority");
17124        let names: Vec<&str> = config
17125            .jobs
17126            .queues
17127            .queues
17128            .iter()
17129            .map(|q| q.name.as_str())
17130            .collect();
17131        assert_eq!(names, ["critical", "default", "low"]);
17132        assert!(config.jobs.queues.queues.iter().all(|q| q.weight == 1));
17133    }
17134
17135    #[test]
17136    fn job_queues_parse_weight_map_as_weighted() {
17137        let config: AutumnConfig = toml::from_str(
17138            r#"
17139            [jobs]
17140            backend = "local"
17141
17142            [jobs.queues]
17143            critical = 4
17144            default = 2
17145            low = 1
17146            "#,
17147        )
17148        .unwrap();
17149        assert!(!config.jobs.queues.strict, "map form is weighted");
17150        let weight = |name: &str| {
17151            config
17152                .jobs
17153                .queues
17154                .queues
17155                .iter()
17156                .find(|q| q.name == name)
17157                .map(|q| q.weight)
17158        };
17159        assert_eq!(weight("critical"), Some(4));
17160        assert_eq!(weight("default"), Some(2));
17161        assert_eq!(weight("low"), Some(1));
17162    }
17163
17164    #[test]
17165    fn job_queues_strict_list_rejects_duplicate_names() {
17166        let err = toml::from_str::<AutumnConfig>(
17167            r#"
17168            [jobs]
17169            queues = ["critical", "default", "critical"]
17170            "#,
17171        )
17172        .unwrap_err()
17173        .to_string();
17174        assert!(
17175            err.contains("duplicate queue name") && err.contains("critical"),
17176            "unexpected error: {err}"
17177        );
17178    }
17179
17180    #[test]
17181    fn job_queues_table_form_parses_caps_and_reserved_slots() {
17182        // Issue #1623: a queue value may be a bare weight OR a table with
17183        // per-queue `concurrency` (cap) and `reserved` (dedicated) slots.
17184        let config: AutumnConfig = toml::from_str(
17185            r"
17186            [jobs.queues]
17187            critical = { weight = 3, reserved = 2 }
17188            bulk = { weight = 1, concurrency = 4 }
17189            default = 2
17190            ",
17191        )
17192        .unwrap();
17193        assert!(!config.jobs.queues.strict, "table form is weighted");
17194        let find = |name: &str| {
17195            config
17196                .jobs
17197                .queues
17198                .queues
17199                .iter()
17200                .find(|q| q.name == name)
17201                .cloned()
17202                .unwrap()
17203        };
17204        let critical = find("critical");
17205        assert_eq!(critical.weight, 3);
17206        assert_eq!(critical.reserved, Some(2));
17207        assert_eq!(critical.concurrency, None);
17208        let bulk = find("bulk");
17209        assert_eq!(bulk.weight, 1);
17210        assert_eq!(bulk.concurrency, Some(4));
17211        assert_eq!(bulk.reserved, None);
17212        // Bare integer still works alongside the table form.
17213        let default = find("default");
17214        assert_eq!(default.weight, 2);
17215        assert_eq!(default.concurrency, None);
17216        assert_eq!(default.reserved, None);
17217    }
17218
17219    #[test]
17220    fn job_queues_table_form_defaults_weight_to_one() {
17221        let config: AutumnConfig = toml::from_str(
17222            r"
17223            [jobs.queues]
17224            critical = { reserved = 1 }
17225            ",
17226        )
17227        .unwrap();
17228        let critical = &config.jobs.queues.queues[0];
17229        assert_eq!(critical.weight, 1, "omitted weight defaults to 1");
17230        assert_eq!(critical.reserved, Some(1));
17231    }
17232
17233    #[test]
17234    fn job_queues_table_form_rejects_zero_weight() {
17235        let err = toml::from_str::<AutumnConfig>(
17236            r"
17237            [jobs.queues]
17238            critical = { weight = 0, reserved = 1 }
17239            ",
17240        )
17241        .unwrap_err()
17242        .to_string();
17243        assert!(
17244            err.contains("weight must be at least 1") && err.contains("critical"),
17245            "unexpected error: {err}"
17246        );
17247    }
17248
17249    #[test]
17250    fn job_queues_table_form_rejects_unknown_setting() {
17251        let err = toml::from_str::<AutumnConfig>(
17252            r"
17253            [jobs.queues]
17254            critical = { weight = 1, bogus = 3 }
17255            ",
17256        )
17257        .unwrap_err()
17258        .to_string();
17259        assert!(err.contains("bogus"), "unexpected error: {err}");
17260    }
17261
17262    #[test]
17263    fn jobs_pin_defaults_empty_and_parses_from_toml() {
17264        let default = AutumnConfig::default();
17265        assert!(default.jobs.pin.is_empty(), "pin is empty by default (AC4)");
17266        let config: AutumnConfig = toml::from_str(
17267            r#"
17268            [jobs]
17269            pin = ["critical", "default"]
17270            "#,
17271        )
17272        .unwrap();
17273        assert_eq!(config.jobs.pin, vec!["critical", "default"]);
17274    }
17275
17276    /// Issue #1623, AC6: `autumn doctor --strict` can only *prove* a zero-coverage
17277    /// gap when the operator declares the fleet topology under `[jobs.fleet]
17278    /// tiers` — every worker tier's `jobs.pin`. That key lives in the same
17279    /// `autumn.toml` the app boots from, so the app's own schema must know it;
17280    /// otherwise declaring the topology that makes the doctor check work is an
17281    /// unknown config key that hard-fails boot under `strict_config_enforce_all`.
17282    #[test]
17283    fn jobs_fleet_tiers_parse_from_toml() {
17284        let default = AutumnConfig::default();
17285        assert!(
17286            default.jobs.fleet.tiers.is_empty(),
17287            "no fleet topology is declared by default (AC4)"
17288        );
17289        let config: AutumnConfig = toml::from_str(
17290            r#"
17291            [jobs.fleet]
17292            tiers = [["critical"], ["bulk", "default"], []]
17293            "#,
17294        )
17295        .unwrap();
17296        assert_eq!(
17297            config.jobs.fleet.tiers,
17298            vec![
17299                vec!["critical".to_owned()],
17300                vec!["bulk".to_owned(), "default".to_owned()],
17301                // An empty inner list is an *unpinned* tier that drains
17302                // everything — a meaningful declaration, not a typo.
17303                Vec::<String>::new(),
17304            ],
17305        );
17306    }
17307
17308    /// The regression this closes: `[jobs.fleet]` is documented for
17309    /// `autumn doctor`, so an operator who declares it must still be able to
17310    /// boot the app with the strictest config validation turned on.
17311    #[test]
17312    fn declared_fleet_topology_boots_under_enforce_all() {
17313        let temp = tempfile::tempdir().unwrap();
17314        let config_path = temp.path().join("autumn.toml");
17315        std::fs::write(
17316            &config_path,
17317            "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
17318             [jobs]\npin = [\"critical\"]\n\n\
17319             [jobs.fleet]\ntiers = [[\"critical\"], [\"bulk\", \"default\"]]\n",
17320        )
17321        .unwrap();
17322
17323        let env = FakeEnv(
17324            [
17325                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
17326                (
17327                    "AUTUMN_MANIFEST_DIR".to_owned(),
17328                    temp.path().to_str().unwrap().to_owned(),
17329                ),
17330            ]
17331            .into(),
17332        );
17333
17334        let res = AutumnConfig::load_with_env(&env);
17335        assert!(
17336            res.is_ok(),
17337            "a declared [jobs.fleet] topology must not be an unknown config key: {res:?}"
17338        );
17339        let config = res.unwrap();
17340        assert_eq!(config.jobs.fleet.tiers.len(), 2);
17341        assert_eq!(config.jobs.pin, vec!["critical".to_owned()]);
17342    }
17343
17344    /// A typo *inside* `[jobs.fleet]` must still be caught — accepting the
17345    /// section must not turn it into an opaque bag that swallows mistakes. The
17346    /// crate catches unknown keys through the strict-config schema walk (not
17347    /// `deny_unknown_fields`), so this asserts it there.
17348    #[test]
17349    fn jobs_fleet_typo_is_caught_by_strict_config() {
17350        let temp = tempfile::tempdir().unwrap();
17351        let config_path = temp.path().join("autumn.toml");
17352        std::fs::write(
17353            &config_path,
17354            "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
17355             [jobs.fleet]\nteirs = [[\"critical\"]]\n",
17356        )
17357        .unwrap();
17358
17359        let env = FakeEnv(
17360            [
17361                ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
17362                (
17363                    "AUTUMN_MANIFEST_DIR".to_owned(),
17364                    temp.path().to_str().unwrap().to_owned(),
17365                ),
17366            ]
17367            .into(),
17368        );
17369
17370        let res = AutumnConfig::load_with_env(&env);
17371        assert!(
17372            res.is_err(),
17373            "a typo inside [jobs.fleet] must not be silently accepted"
17374        );
17375        let err_str = format!("{:?}", res.err().unwrap());
17376        assert!(
17377            err_str.contains("teirs"),
17378            "error should name the typo: {err_str}"
17379        );
17380    }
17381
17382    #[test]
17383    fn jobs_pin_env_override_is_comma_separated() {
17384        let env = MockEnv::new().with("AUTUMN_JOBS__PIN", "critical, bulk ,");
17385        let mut config = AutumnConfig::default();
17386        config.apply_jobs_env_overrides_with_env(&env);
17387        assert_eq!(
17388            config.jobs.pin,
17389            vec!["critical".to_string(), "bulk".to_string()],
17390            "trims whitespace and drops empty entries"
17391        );
17392    }
17393
17394    #[test]
17395    fn job_queues_weighted_rejects_zero_weight() {
17396        let err = toml::from_str::<AutumnConfig>(
17397            r"
17398            [jobs.queues]
17399            critical = 4
17400            default = 0
17401            ",
17402        )
17403        .unwrap_err()
17404        .to_string();
17405        assert!(
17406            err.contains("weight must be at least 1") && err.contains("default"),
17407            "unexpected error: {err}"
17408        );
17409    }
17410
17411    #[test]
17412    fn channels_defaults_to_in_process_backend() {
17413        let config = AutumnConfig::default();
17414
17415        assert_eq!(config.channels.backend, ChannelBackend::InProcess);
17416        assert_eq!(config.channels.capacity, 32);
17417        assert_eq!(config.channels.replay_buffer, 256);
17418        assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
17419        assert!(config.channels.redis.url.is_none());
17420    }
17421
17422    #[test]
17423    fn channels_env_overrides_fields() {
17424        let env = MockEnv::new()
17425            .with("AUTUMN_CHANNELS__BACKEND", "redis")
17426            .with("AUTUMN_CHANNELS__CAPACITY", "128")
17427            .with("AUTUMN_CHANNELS__REPLAY_BUFFER", "512")
17428            .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
17429            .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
17430        let mut config = AutumnConfig::default();
17431
17432        config.apply_env_overrides_with_env(&env);
17433
17434        assert_eq!(config.channels.backend, ChannelBackend::Redis);
17435        assert_eq!(config.channels.capacity, 128);
17436        assert_eq!(config.channels.replay_buffer, 512);
17437        assert_eq!(
17438            config.channels.redis.url.as_deref(),
17439            Some("redis://channels:6379/4")
17440        );
17441        assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
17442    }
17443
17444    #[test]
17445    fn channels_toml_deserializes_redis_backend() {
17446        let config: AutumnConfig = toml::from_str(
17447            r#"
17448            [channels]
17449            backend = "redis"
17450            capacity = 64
17451
17452            [channels.redis]
17453            url = "redis://localhost:6379/5"
17454            key_prefix = "demo:channels"
17455            "#,
17456        )
17457        .unwrap();
17458
17459        assert_eq!(config.channels.backend, ChannelBackend::Redis);
17460        assert_eq!(config.channels.capacity, 64);
17461        assert_eq!(
17462            config.channels.redis.url.as_deref(),
17463            Some("redis://localhost:6379/5")
17464        );
17465        assert_eq!(config.channels.redis.key_prefix, "demo:channels");
17466    }
17467
17468    #[test]
17469    fn env_override_invalid_jobs_numeric_values_ignored() {
17470        let env = MockEnv::new()
17471            .with("AUTUMN_JOBS__WORKERS", "many")
17472            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
17473            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
17474        let mut config = AutumnConfig::default();
17475        config.apply_env_overrides_with_env(&env);
17476
17477        assert_eq!(config.jobs.workers, 1);
17478        assert_eq!(config.jobs.max_attempts, 5);
17479        assert_eq!(config.jobs.initial_backoff_ms, 250);
17480    }
17481
17482    // ── Server env override tests ────────────────────────────────
17483
17484    #[test]
17485    fn env_override_server_port() {
17486        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
17487        let mut config = AutumnConfig::default();
17488        config.apply_env_overrides_with_env(&env);
17489        assert_eq!(config.server.port, 8080);
17490    }
17491
17492    #[test]
17493    fn parse_env_works() {
17494        let env = MockEnv::new().with("SOME_NUM", "123");
17495        let mut target: u32 = 0;
17496        parse_env(&env, "SOME_NUM", &mut target);
17497        assert_eq!(target, 123);
17498
17499        let env_err = MockEnv::new().with("SOME_NUM", "abc");
17500        let mut target_err: u32 = 0;
17501        parse_env(&env_err, "SOME_NUM", &mut target_err);
17502        assert_eq!(target_err, 0); // Unchanged
17503    }
17504
17505    #[test]
17506    fn parse_env_option_string_works() {
17507        let env = MockEnv::new().with("SOME_OPT", "val");
17508        let mut target = None;
17509        parse_env_option_string(&env, "SOME_OPT", &mut target);
17510        assert_eq!(target, Some("val".to_string()));
17511
17512        let env_empty = MockEnv::new().with("SOME_OPT", "");
17513        let mut target_empty = Some("old".to_string());
17514        parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
17515        assert_eq!(target_empty, None);
17516    }
17517
17518    #[test]
17519    fn parse_env_string_works() {
17520        let env = MockEnv::new().with("SOME_STR", "val");
17521        let mut target = "old".to_string();
17522        parse_env_string(&env, "SOME_STR", &mut target);
17523        assert_eq!(target, "val");
17524    }
17525
17526    // ── server_timing_enabled resolver tests ────────────────────
17527
17528    fn cfg_with_profile(profile: Option<&str>) -> AutumnConfig {
17529        AutumnConfig {
17530            profile: profile.map(str::to_owned),
17531            ..Default::default()
17532        }
17533    }
17534
17535    #[test]
17536    fn server_timing_defaults_on_in_dev_profile() {
17537        let cfg = cfg_with_profile(Some("dev"));
17538        assert!(server_timing_enabled(&cfg));
17539
17540        let cfg = cfg_with_profile(Some("development"));
17541        assert!(server_timing_enabled(&cfg));
17542    }
17543
17544    #[test]
17545    fn server_timing_defaults_off_in_prod_and_test_profiles() {
17546        let cfg = cfg_with_profile(Some("prod"));
17547        assert!(!server_timing_enabled(&cfg));
17548
17549        let cfg = cfg_with_profile(Some("production"));
17550        assert!(!server_timing_enabled(&cfg));
17551
17552        let cfg = cfg_with_profile(Some("test"));
17553        assert!(!server_timing_enabled(&cfg));
17554
17555        let cfg = cfg_with_profile(None);
17556        assert!(!server_timing_enabled(&cfg));
17557    }
17558
17559    #[test]
17560    fn server_timing_explicit_config_overrides_profile_default() {
17561        let mut cfg = cfg_with_profile(Some("prod"));
17562        cfg.observability.server_timing = Some(true);
17563        assert!(server_timing_enabled(&cfg));
17564
17565        let mut cfg = cfg_with_profile(Some("dev"));
17566        cfg.observability.server_timing = Some(false);
17567        assert!(!server_timing_enabled(&cfg));
17568    }
17569
17570    #[test]
17571    fn server_timing_env_override_wires_into_dispatcher() {
17572        let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "true");
17573        let mut config = cfg_with_profile(Some("prod"));
17574        config.apply_env_overrides_with_env(&env);
17575        assert_eq!(config.observability.server_timing, Some(true));
17576        assert!(server_timing_enabled(&config));
17577
17578        let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "false");
17579        let mut config = cfg_with_profile(Some("dev"));
17580        config.apply_env_overrides_with_env(&env);
17581        assert_eq!(config.observability.server_timing, Some(false));
17582        assert!(!server_timing_enabled(&config));
17583    }
17584
17585    #[test]
17586    fn cost_section_defaults_and_toml() {
17587        let config = AutumnConfig::default();
17588        assert!(!config.cost.enabled);
17589        assert_eq!(config.cost.defer_threshold, None);
17590        assert_eq!(config.cost.defer_recheck_secs, 30);
17591        assert_eq!(config.cost.max_tenants, 1000);
17592        assert_eq!(config.cost.signal_refresh_secs, 5);
17593
17594        let config: AutumnConfig =
17595            toml::from_str("[cost]\nenabled = true\ndefer_threshold = 400.5\n").expect("parse");
17596        assert!(config.cost.enabled);
17597        assert_eq!(config.cost.defer_threshold, Some(400.5));
17598        assert_eq!(
17599            config.cost.defer_recheck_secs, 30,
17600            "unset keys keep defaults"
17601        );
17602    }
17603
17604    #[test]
17605    fn cost_validation_names_the_bad_key() {
17606        assert!(CostConfig::default().validate().is_ok());
17607        let cost = CostConfig {
17608            defer_recheck_secs: 0,
17609            ..CostConfig::default()
17610        };
17611        assert!(cost.validate().unwrap_err().contains("defer_recheck_secs"));
17612        let cost = CostConfig {
17613            signal_refresh_secs: 0,
17614            ..CostConfig::default()
17615        };
17616        assert!(cost.validate().unwrap_err().contains("signal_refresh_secs"));
17617        for bad in [-1.0, f64::NAN, f64::INFINITY] {
17618            let cost = CostConfig {
17619                defer_threshold: Some(bad),
17620                ..CostConfig::default()
17621            };
17622            assert!(cost.validate().unwrap_err().contains("defer_threshold"));
17623        }
17624        let mut config = AutumnConfig::default();
17625        config.cost.defer_recheck_secs = 0;
17626        assert!(config.validate().is_err(), "AutumnConfig::validate runs it");
17627    }
17628
17629    #[test]
17630    fn cost_env_overrides_wire_into_dispatcher() {
17631        let env = MockEnv::new()
17632            .with("AUTUMN_COST__ENABLED", "true")
17633            .with("AUTUMN_COST__DEFER_THRESHOLD", "250")
17634            .with("AUTUMN_COST__DEFER_RECHECK_SECS", "10")
17635            .with("AUTUMN_COST__MAX_TENANTS", "7")
17636            .with("AUTUMN_COST__SIGNAL_REFRESH_SECS", "2");
17637        let mut config = AutumnConfig::default();
17638        config.apply_env_overrides_with_env(&env);
17639        assert!(config.cost.enabled);
17640        assert_eq!(config.cost.defer_threshold, Some(250.0));
17641        assert_eq!(config.cost.defer_recheck_secs, 10);
17642        assert_eq!(config.cost.max_tenants, 7);
17643        assert_eq!(config.cost.signal_refresh_secs, 2);
17644    }
17645
17646    #[test]
17647    fn parse_env_bool_works() {
17648        let env = MockEnv::new().with("SOME_BOOL", "true");
17649        let mut target = false;
17650        parse_env_bool(&env, "SOME_BOOL", &mut target);
17651        assert!(target);
17652
17653        let env2 = MockEnv::new().with("SOME_BOOL", "1");
17654        let mut target2 = false;
17655        parse_env_bool(&env2, "SOME_BOOL", &mut target2);
17656        assert!(target2);
17657
17658        let env3 = MockEnv::new().with("SOME_BOOL", "0");
17659        let mut target3 = true;
17660        parse_env_bool(&env3, "SOME_BOOL", &mut target3);
17661        assert!(!target3);
17662
17663        let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
17664        let mut target_err = true;
17665        parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
17666        assert!(target_err); // Unchanged
17667    }
17668
17669    #[test]
17670    fn parse_env_csv_works() {
17671        let env = MockEnv::new().with("SOME_CSV", "a, b,c");
17672        let mut target = vec![];
17673        parse_env_csv(&env, "SOME_CSV", &mut target);
17674        assert_eq!(target, vec!["a", "b", "c"]);
17675    }
17676
17677    #[test]
17678    fn env_override_tenancy_quota_bytes() {
17679        // Unset: default stays 0 (unlimited).
17680        let env = MockEnv::new();
17681        let mut config = AutumnConfig::default();
17682        config.apply_env_overrides_with_env(&env);
17683        assert_eq!(config.tenancy.quota_bytes, 0);
17684
17685        // Set via env: override is applied through the dispatcher.
17686        let env = MockEnv::new().with("AUTUMN_TENANCY__QUOTA_BYTES", "1048576");
17687        let mut config = AutumnConfig::default();
17688        config.apply_env_overrides_with_env(&env);
17689        assert_eq!(config.tenancy.quota_bytes, 1_048_576);
17690    }
17691
17692    #[test]
17693    fn env_override_tenancy_enabled() {
17694        // Unset: default stays false.
17695        let env = MockEnv::new();
17696        let mut config = AutumnConfig::default();
17697        config.apply_env_overrides_with_env(&env);
17698        assert!(!config.tenancy.enabled);
17699
17700        let env = MockEnv::new().with("AUTUMN_TENANCY__ENABLED", "true");
17701        let mut config = AutumnConfig::default();
17702        config.apply_env_overrides_with_env(&env);
17703        assert!(config.tenancy.enabled);
17704    }
17705
17706    #[test]
17707    fn env_override_tenancy_string_fields() {
17708        let env = MockEnv::new()
17709            .with("AUTUMN_TENANCY__SOURCE", "jwt")
17710            .with("AUTUMN_TENANCY__HEADER_NAME", "x-org")
17711            .with("AUTUMN_TENANCY__SESSION_KEY", "org_id")
17712            .with("AUTUMN_TENANCY__JWT_CLAIM", "org")
17713            .with("AUTUMN_TENANCY__JWT_ISSUER", "https://issuer.example")
17714            .with("AUTUMN_TENANCY__JWT_AUDIENCE", "autumn-api")
17715            .with("AUTUMN_TENANCY__BASE_DOMAIN", "apps.example.com")
17716            .with("AUTUMN_TENANCY__LOGIN_REDIRECT", "/login")
17717            .with("AUTUMN_TENANCY__PUBLIC_PATHS", "/login, /signup ,/assets");
17718        let mut config = AutumnConfig::default();
17719        config.apply_env_overrides_with_env(&env);
17720        assert_eq!(config.tenancy.source, "jwt");
17721        assert_eq!(config.tenancy.header_name, "x-org");
17722        assert_eq!(config.tenancy.session_key, "org_id");
17723        assert_eq!(config.tenancy.jwt_claim, "org");
17724        assert_eq!(
17725            config.tenancy.jwt_issuer.as_deref(),
17726            Some("https://issuer.example")
17727        );
17728        assert_eq!(config.tenancy.jwt_audience.as_deref(), Some("autumn-api"));
17729        assert_eq!(
17730            config.tenancy.base_domain.as_deref(),
17731            Some("apps.example.com")
17732        );
17733        assert_eq!(config.tenancy.login_redirect.as_deref(), Some("/login"));
17734        assert_eq!(
17735            config.tenancy.public_paths,
17736            vec!["/login", "/signup", "/assets"]
17737        );
17738    }
17739
17740    #[test]
17741    fn env_override_isolation_knobs() {
17742        // Issue #3072: every isolation knob is settable from the environment.
17743        let env = MockEnv::new()
17744            .with("AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS", "32")
17745            .with("AUTUMN_TENANCY__MAX_DB_CONNECTIONS", "4")
17746            .with("AUTUMN_JOBS__TENANTS__MAX_CONCURRENT", "2")
17747            .with("AUTUMN_JOBS__TENANTS__LANES", "8")
17748            .with("AUTUMN_JOBS__TENANTS__LANES_PER_TENANT", "3")
17749            .with("AUTUMN_JOBS__POSTGRES__SHARD_LOCAL", "true");
17750        let mut config = AutumnConfig::default();
17751        config.apply_env_overrides_with_env(&env);
17752        assert_eq!(config.tenancy.max_concurrent_requests, 32);
17753        assert_eq!(config.tenancy.max_db_connections, 4);
17754        assert_eq!(config.jobs.tenants.max_concurrent, 2);
17755        assert_eq!(config.jobs.tenants.lanes, 8);
17756        assert_eq!(config.jobs.tenants.lanes_per_tenant, 3);
17757        assert!(config.jobs.postgres.shard_local);
17758    }
17759
17760    #[test]
17761    fn env_override_tenancy_eviction_knobs() {
17762        // Unset: defaults stay 0 (unbounded / disabled).
17763        let env = MockEnv::new();
17764        let mut config = AutumnConfig::default();
17765        config.apply_env_overrides_with_env(&env);
17766        assert_eq!(config.tenancy.max_cells, 0);
17767        assert_eq!(config.tenancy.idle_ttl_secs, 0);
17768
17769        let env = MockEnv::new()
17770            .with("AUTUMN_TENANCY__MAX_CELLS", "512")
17771            .with("AUTUMN_TENANCY__IDLE_TTL_SECS", "900");
17772        let mut config = AutumnConfig::default();
17773        config.apply_env_overrides_with_env(&env);
17774        assert_eq!(config.tenancy.max_cells, 512);
17775        assert_eq!(config.tenancy.idle_ttl_secs, 900);
17776    }
17777
17778    #[test]
17779    fn env_override_tenancy_secret() {
17780        use secrecy::ExposeSecret;
17781
17782        // Unset: default stays None.
17783        let env = MockEnv::new();
17784        let mut config = AutumnConfig::default();
17785        config.apply_env_overrides_with_env(&env);
17786        assert!(config.tenancy.jwt_secret.is_none());
17787
17788        // Set via env: wrapped as a SecretString, trimmed.
17789        let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", "  s3cr3t-signing-key  ");
17790        let mut config = AutumnConfig::default();
17791        config.apply_env_overrides_with_env(&env);
17792        assert_eq!(
17793            config
17794                .tenancy
17795                .jwt_secret
17796                .as_ref()
17797                .map(|s| s.expose_secret().to_owned()),
17798            Some("s3cr3t-signing-key".to_string())
17799        );
17800
17801        // Empty value clears the secret.
17802        let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", "   ");
17803        let mut config = AutumnConfig::default();
17804        config.tenancy.jwt_secret = Some(secrecy::SecretString::from("preexisting".to_string()));
17805        config.apply_env_overrides_with_env(&env);
17806        assert!(config.tenancy.jwt_secret.is_none());
17807    }
17808
17809    #[test]
17810    fn env_override_rate_limit_trusted_proxies() {
17811        let env = MockEnv::new().with(
17812            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
17813            "10.0.0.10, 203.0.113.0/24",
17814        );
17815        let mut config = AutumnConfig::default();
17816        config.apply_env_overrides_with_env(&env);
17817        assert_eq!(
17818            config.security.rate_limit.trusted_proxies,
17819            vec!["10.0.0.10", "203.0.113.0/24"]
17820        );
17821    }
17822
17823    #[test]
17824    fn env_override_rate_limit_backend_redis() {
17825        use crate::security::config::RateLimitBackend;
17826        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
17827        let mut config = AutumnConfig::default();
17828        config.apply_env_overrides_with_env(&env);
17829        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
17830    }
17831
17832    #[test]
17833    fn env_override_rate_limit_backend_memory() {
17834        use crate::security::config::RateLimitBackend;
17835        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
17836        let mut config = AutumnConfig::default();
17837        config.apply_env_overrides_with_env(&env);
17838        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
17839    }
17840
17841    #[test]
17842    fn env_override_rate_limit_backend_invalid_ignored() {
17843        use crate::security::config::RateLimitBackend;
17844        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
17845        let mut config = AutumnConfig::default();
17846        config.apply_env_overrides_with_env(&env);
17847        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
17848    }
17849
17850    #[cfg(feature = "redis")]
17851    #[test]
17852    fn env_override_rate_limit_on_backend_failure_fail_closed() {
17853        use crate::security::config::RateLimitBackendFailure;
17854        let env = MockEnv::new().with(
17855            "AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
17856            "fail_closed",
17857        );
17858        let mut config = AutumnConfig::default();
17859        config.apply_env_overrides_with_env(&env);
17860        assert_eq!(
17861            config.security.rate_limit.on_backend_failure,
17862            RateLimitBackendFailure::FailClosed
17863        );
17864    }
17865
17866    #[cfg(feature = "redis")]
17867    #[test]
17868    fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
17869        use crate::security::config::RateLimitBackendFailure;
17870        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
17871        let mut config = AutumnConfig::default();
17872        config.apply_env_overrides_with_env(&env);
17873        assert_eq!(
17874            config.security.rate_limit.on_backend_failure,
17875            RateLimitBackendFailure::FailOpen
17876        );
17877    }
17878
17879    #[cfg(feature = "redis")]
17880    #[test]
17881    fn env_override_rate_limit_redis_url() {
17882        let env = MockEnv::new().with(
17883            "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
17884            "redis://myhost:6379",
17885        );
17886        let mut config = AutumnConfig::default();
17887        config.apply_env_overrides_with_env(&env);
17888        assert_eq!(
17889            config.security.rate_limit.redis.url.as_deref(),
17890            Some("redis://myhost:6379")
17891        );
17892    }
17893
17894    #[cfg(feature = "redis")]
17895    #[test]
17896    fn env_override_rate_limit_redis_key_prefix() {
17897        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
17898        let mut config = AutumnConfig::default();
17899        config.apply_env_overrides_with_env(&env);
17900        assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
17901    }
17902
17903    #[test]
17904    fn env_override_server_host() {
17905        let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
17906        let mut config = AutumnConfig::default();
17907        config.apply_env_overrides_with_env(&env);
17908        assert_eq!(config.server.host, "0.0.0.0");
17909    }
17910
17911    #[test]
17912    fn env_override_server_shutdown_timeout() {
17913        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
17914        let mut config = AutumnConfig::default();
17915        config.apply_env_overrides_with_env(&env);
17916        assert_eq!(config.server.shutdown_timeout_secs, 60);
17917    }
17918
17919    #[test]
17920    fn env_override_invalid_server_port_ignored() {
17921        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
17922        let mut config = AutumnConfig::default();
17923        config.apply_env_overrides_with_env(&env);
17924        assert_eq!(config.server.port, 3000);
17925    }
17926
17927    #[test]
17928    fn env_override_invalid_shutdown_timeout_ignored() {
17929        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
17930        let mut config = AutumnConfig::default();
17931        config.apply_env_overrides_with_env(&env);
17932        assert_eq!(config.server.shutdown_timeout_secs, 30);
17933    }
17934
17935    #[test]
17936    fn server_config_defaults_unix_socket_none() {
17937        let config = AutumnConfig::default();
17938        assert!(config.server.unix_socket.is_none());
17939    }
17940
17941    #[test]
17942    fn env_override_server_unix_socket() {
17943        let env = MockEnv::new().with("AUTUMN_SERVER__UNIX_SOCKET", "/run/autumn/app.sock");
17944        let mut config = AutumnConfig::default();
17945        config.apply_env_overrides_with_env(&env);
17946        assert_eq!(
17947            config.server.unix_socket.as_deref(),
17948            Some("/run/autumn/app.sock")
17949        );
17950    }
17951
17952    #[test]
17953    fn unix_socket_parses_from_toml() {
17954        let config: AutumnConfig = toml::from_str(
17955            r#"
17956            [server]
17957            unix_socket = "/tmp/autumn.sock"
17958            "#,
17959        )
17960        .expect("config with server.unix_socket should parse");
17961        assert_eq!(
17962            config.server.unix_socket.as_deref(),
17963            Some("/tmp/autumn.sock")
17964        );
17965    }
17966
17967    // ── server.upgrade (#1674) ────────────────────────────────────
17968
17969    #[test]
17970    fn server_upgrade_config_defaults_enabled_with_a_30s_readiness_budget() {
17971        // On by default: SIGUSR2's own default disposition terminates the
17972        // process, so an app that ignores this feature is strictly safer with
17973        // the handler installed than without it.
17974        let config = AutumnConfig::default();
17975        assert!(config.server.upgrade.enabled);
17976        assert_eq!(config.server.upgrade.ready_timeout_secs, 30);
17977    }
17978
17979    #[test]
17980    fn server_upgrade_parses_from_toml() {
17981        let config: AutumnConfig = toml::from_str(
17982            r"
17983            [server.upgrade]
17984            enabled = false
17985            ready_timeout_secs = 5
17986            ",
17987        )
17988        .expect("config with [server.upgrade] should parse");
17989        assert!(!config.server.upgrade.enabled);
17990        assert_eq!(config.server.upgrade.ready_timeout_secs, 5);
17991    }
17992
17993    #[test]
17994    fn env_overrides_server_upgrade() {
17995        // The deploy-time knob an operator reaches for first is the env var.
17996        let env = MockEnv::new()
17997            .with("AUTUMN_SERVER__UPGRADE__ENABLED", "false")
17998            .with("AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS", "90");
17999        let mut config = AutumnConfig::default();
18000        config.apply_env_overrides_with_env(&env);
18001        assert!(!config.server.upgrade.enabled);
18002        assert_eq!(config.server.upgrade.ready_timeout_secs, 90);
18003    }
18004
18005    // ── server.tls (#1603) ────────────────────────────────────────
18006
18007    #[test]
18008    fn server_config_defaults_tls_none() {
18009        // Default must keep plain HTTP so existing apps are unaffected.
18010        let config = AutumnConfig::default();
18011        assert!(config.server.tls.is_none());
18012    }
18013
18014    #[test]
18015    fn server_tls_parses_from_toml() {
18016        let config: AutumnConfig = toml::from_str(
18017            r#"
18018            [server.tls]
18019            cert_path = "/etc/autumn/tls/fullchain.pem"
18020            key_path = "/etc/autumn/tls/privkey.pem"
18021            "#,
18022        )
18023        .expect("config with [server.tls] should parse");
18024        let tls = config.server.tls.expect("tls configured");
18025        assert_eq!(
18026            tls.cert_path,
18027            Some(std::path::PathBuf::from("/etc/autumn/tls/fullchain.pem"))
18028        );
18029        assert_eq!(
18030            tls.key_path,
18031            Some(std::path::PathBuf::from("/etc/autumn/tls/privkey.pem"))
18032        );
18033        // Reload interval and handshake timeout default when omitted.
18034        assert_eq!(tls.reload_interval_secs, 60);
18035        assert_eq!(tls.handshake_timeout_secs, 10);
18036        // No ACME section → static-cert mode.
18037        assert!(tls.acme.is_none());
18038        assert!(tls.validate().is_ok());
18039    }
18040
18041    #[test]
18042    fn server_tls_handshake_timeout_parses_from_toml() {
18043        let config: AutumnConfig = toml::from_str(
18044            r#"
18045            [server.tls]
18046            cert_path = "cert.pem"
18047            key_path = "key.pem"
18048            handshake_timeout_secs = 25
18049            "#,
18050        )
18051        .expect("config with [server.tls] handshake_timeout_secs should parse");
18052        assert_eq!(config.server.tls.unwrap().handshake_timeout_secs, 25);
18053    }
18054
18055    #[test]
18056    fn server_tls_reload_interval_parses_from_toml() {
18057        let config: AutumnConfig = toml::from_str(
18058            r#"
18059            [server.tls]
18060            cert_path = "cert.pem"
18061            key_path = "key.pem"
18062            reload_interval_secs = 120
18063            "#,
18064        )
18065        .expect("config with [server.tls] reload_interval_secs should parse");
18066        assert_eq!(config.server.tls.unwrap().reload_interval_secs, 120);
18067    }
18068
18069    #[test]
18070    fn env_override_materializes_server_tls() {
18071        // A fully env-driven deployment can enable direct HTTPS with no
18072        // [server.tls] section in autumn.toml.
18073        let env = MockEnv::new()
18074            .with("AUTUMN_SERVER__TLS__CERT_PATH", "/env/cert.pem")
18075            .with("AUTUMN_SERVER__TLS__KEY_PATH", "/env/key.pem")
18076            .with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "90")
18077            .with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
18078        let mut config = AutumnConfig::default();
18079        assert!(config.server.tls.is_none());
18080        config.apply_env_overrides_with_env(&env);
18081        let tls = config.server.tls.expect("env should materialize tls");
18082        assert_eq!(
18083            tls.cert_path,
18084            Some(std::path::PathBuf::from("/env/cert.pem"))
18085        );
18086        assert_eq!(tls.key_path, Some(std::path::PathBuf::from("/env/key.pem")));
18087        assert_eq!(tls.reload_interval_secs, 90);
18088        assert_eq!(tls.handshake_timeout_secs, 5);
18089    }
18090
18091    #[test]
18092    fn env_override_updates_existing_server_tls_cert() {
18093        // An env var overrides just the cert path of a TOML-configured section,
18094        // leaving the key path intact.
18095        let mut config: AutumnConfig = toml::from_str(
18096            r#"
18097            [server.tls]
18098            cert_path = "toml-cert.pem"
18099            key_path = "toml-key.pem"
18100            "#,
18101        )
18102        .unwrap();
18103        let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "override-cert.pem");
18104        config.apply_env_overrides_with_env(&env);
18105        let tls = config.server.tls.expect("tls configured");
18106        assert_eq!(
18107            tls.cert_path,
18108            Some(std::path::PathBuf::from("override-cert.pem"))
18109        );
18110        assert_eq!(tls.key_path, Some(std::path::PathBuf::from("toml-key.pem")));
18111    }
18112
18113    #[test]
18114    fn no_tls_env_leaves_tls_none() {
18115        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
18116        let mut config = AutumnConfig::default();
18117        config.apply_env_overrides_with_env(&env);
18118        assert!(config.server.tls.is_none());
18119    }
18120
18121    // ── deploy (#1607) ────────────────────────────────────────────
18122
18123    #[test]
18124    fn deploy_absent_is_none() {
18125        // No [deploy] section → the field stays None so existing apps are
18126        // unaffected.
18127        let config = AutumnConfig::default();
18128        assert!(config.deploy.is_none());
18129        let parsed: AutumnConfig = toml::from_str("[server]\nport = 3000\n")
18130            .expect("config without [deploy] should parse");
18131        assert!(parsed.deploy.is_none());
18132    }
18133
18134    #[test]
18135    fn deploy_defaults_from_bare_table() {
18136        // A bare [deploy] table materializes the section with every optional
18137        // field at its documented default.
18138        let config: AutumnConfig =
18139            toml::from_str("[deploy]\n").expect("bare [deploy] table should parse");
18140        let deploy = config.deploy.expect("deploy configured");
18141        assert_eq!(deploy.host, None);
18142        assert_eq!(deploy.user, "root");
18143        assert_eq!(deploy.ssh_port, 22);
18144        assert_eq!(deploy.app_name, None);
18145        assert_eq!(deploy.app_dir, None);
18146        assert_eq!(deploy.service_name, None);
18147        assert_eq!(deploy.readiness_timeout_secs, 60);
18148        assert_eq!(deploy.keep_releases, 3);
18149        // #1607: host preparation is ON by default, so the documented "target host
18150        // precondition is at most a stock Ubuntu LTS" holds for a bare table. A
18151        // plain `#[serde(default)]` here would silently deserialize `false` for
18152        // every real user while `DeployConfig::default()` stayed `true`.
18153        assert!(deploy.install_proxy);
18154    }
18155
18156    #[test]
18157    fn deploy_full_table_parses() {
18158        let config: AutumnConfig = toml::from_str(
18159            r#"
18160            [deploy]
18161            host = "203.0.113.10"
18162            user = "deploy"
18163            ssh_port = 2222
18164            app_name = "myapp"
18165            app_dir = "/srv/myapp"
18166            service_name = "myapp-web"
18167            readiness_timeout_secs = 90
18168            keep_releases = 5
18169            install_proxy = false
18170            "#,
18171        )
18172        .expect("full [deploy] table should parse");
18173        let deploy = config.deploy.expect("deploy configured");
18174        assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18175        assert_eq!(deploy.user, "deploy");
18176        assert_eq!(deploy.ssh_port, 2222);
18177        assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
18178        assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
18179        assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
18180        assert_eq!(deploy.readiness_timeout_secs, 90);
18181        assert_eq!(deploy.keep_releases, 5);
18182        assert!(
18183            !deploy.install_proxy,
18184            "the host-prep opt-out parses (#1607)"
18185        );
18186        assert!(deploy.validate().is_ok());
18187    }
18188
18189    #[test]
18190    fn deploy_validate_rejects_missing_host() {
18191        // Missing host: a bare table is valid at rest but validate() rejects it.
18192        let missing = DeployConfig::default();
18193        let err = missing
18194            .validate()
18195            .expect_err("missing host must be rejected");
18196        assert!(
18197            err.contains("host"),
18198            "error should name the missing key: {err}"
18199        );
18200
18201        // Present-but-blank host is also rejected.
18202        let blank = DeployConfig {
18203            host: Some("   ".to_owned()),
18204            ..DeployConfig::default()
18205        };
18206        assert!(blank.validate().is_err());
18207
18208        // A real host passes.
18209        let ok = DeployConfig {
18210            host: Some("example.com".to_owned()),
18211            ..DeployConfig::default()
18212        };
18213        assert!(ok.validate().is_ok());
18214    }
18215
18216    #[test]
18217    fn env_override_materializes_deploy() {
18218        // A CI/VPS deploy can keep the target host out of autumn.toml and drive
18219        // the whole [deploy] section through AUTUMN_DEPLOY__* env vars.
18220        let env = MockEnv::new()
18221            .with("AUTUMN_DEPLOY__HOST", "203.0.113.10")
18222            .with("AUTUMN_DEPLOY__USER", "deploy")
18223            .with("AUTUMN_DEPLOY__SSH_PORT", "2222")
18224            .with("AUTUMN_DEPLOY__APP_NAME", "myapp")
18225            .with("AUTUMN_DEPLOY__APP_DIR", "/srv/myapp")
18226            .with("AUTUMN_DEPLOY__SERVICE_NAME", "myapp-web")
18227            .with("AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS", "90")
18228            .with("AUTUMN_DEPLOY__KEEP_RELEASES", "5")
18229            .with("AUTUMN_DEPLOY__PROFILE", "staging");
18230        let mut config = AutumnConfig::default();
18231        assert!(config.deploy.is_none());
18232        config.apply_env_overrides_with_env(&env);
18233        let deploy = config.deploy.expect("env should materialize deploy");
18234        assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18235        assert_eq!(deploy.user, "deploy");
18236        assert_eq!(deploy.ssh_port, 2222);
18237        assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
18238        assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
18239        assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
18240        assert_eq!(deploy.readiness_timeout_secs, 90);
18241        assert_eq!(deploy.keep_releases, 5);
18242        assert_eq!(deploy.profile, "staging");
18243        assert!(deploy.validate().is_ok());
18244    }
18245
18246    #[test]
18247    fn env_override_declines_deploy_host_preparation() {
18248        // #1607: a CI pipeline deploying to pre-provisioned hosts must be able to
18249        // decline host preparation without editing `autumn.toml`. The key is also in
18250        // the presence probe, so setting only it materializes `[deploy]` — otherwise
18251        // the override would be silently skipped for an env-only config.
18252        let env = MockEnv::new().with("AUTUMN_DEPLOY__INSTALL_PROXY", "false");
18253        let mut config = AutumnConfig::default();
18254        assert!(config.deploy.is_none());
18255        config.apply_env_overrides_with_env(&env);
18256        let deploy = config.deploy.expect("env should materialize deploy");
18257        assert!(!deploy.install_proxy);
18258
18259        // …and it wins over TOML, like every other deploy override.
18260        let mut from_toml: AutumnConfig =
18261            toml::from_str("[deploy]\nhost = \"203.0.113.10\"\ninstall_proxy = true\n").unwrap();
18262        from_toml.apply_env_overrides_with_env(&env);
18263        assert!(!from_toml.deploy.expect("deploy configured").install_proxy);
18264    }
18265
18266    #[test]
18267    fn deploy_bake_defaults_to_off() {
18268        let config: AutumnConfig = toml::from_str("[deploy]\nhost = \"h\"\n").unwrap();
18269        let bake = config.deploy.expect("deploy").bake;
18270        assert_eq!(bake.duration_secs, 0);
18271        assert_eq!(bake.interval_secs, 10);
18272        assert_eq!(bake.min_requests, 20);
18273        assert!(bake.max_error_rate.is_none());
18274        assert!(bake.max_p99_ms.is_none());
18275    }
18276
18277    #[test]
18278    fn deploy_bake_parses_from_toml() {
18279        let config: AutumnConfig = toml::from_str(
18280            "[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\n\
18281             interval_secs = 15\nmin_requests = 50\nmax_error_rate = 0.01\nmax_p99_ms = 400\n",
18282        )
18283        .unwrap();
18284        let bake = config.deploy.expect("deploy").bake;
18285        assert_eq!(bake.duration_secs, 300);
18286        assert_eq!(bake.interval_secs, 15);
18287        assert_eq!(bake.min_requests, 50);
18288        assert_eq!(bake.max_error_rate, Some(0.01));
18289        assert_eq!(bake.max_p99_ms, Some(400));
18290    }
18291
18292    #[test]
18293    fn env_override_wins_over_toml_deploy_bake_and_ignores_a_bad_value() {
18294        let mut config: AutumnConfig = toml::from_str(
18295            "[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\nmax_p99_ms = 400\n",
18296        )
18297        .unwrap();
18298        let env = MockEnv::new()
18299            .with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "60")
18300            // Not a number: the TOML value stays.
18301            .with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "5m");
18302        config.apply_env_overrides_with_env(&env);
18303        let bake = config.deploy.expect("deploy").bake;
18304        assert_eq!(bake.duration_secs, 60);
18305        assert_eq!(bake.max_p99_ms, Some(400));
18306    }
18307
18308    #[test]
18309    fn env_override_sets_every_deploy_bake_key() {
18310        // Each key alone materializes [deploy].
18311        let env = MockEnv::new()
18312            .with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "120")
18313            .with("AUTUMN_DEPLOY__BAKE__INTERVAL_SECS", "5")
18314            .with("AUTUMN_DEPLOY__BAKE__MIN_REQUESTS", "7")
18315            .with("AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE", "0.02")
18316            .with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "300");
18317        let mut config = AutumnConfig::default();
18318        config.apply_env_overrides_with_env(&env);
18319        let bake = config.deploy.expect("env should materialize deploy").bake;
18320        assert_eq!(bake.duration_secs, 120);
18321        assert_eq!(bake.interval_secs, 5);
18322        assert_eq!(bake.min_requests, 7);
18323        assert_eq!(bake.max_error_rate, Some(0.02));
18324        assert_eq!(bake.max_p99_ms, Some(300));
18325
18326        let only = MockEnv::new().with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "250");
18327        let mut config = AutumnConfig::default();
18328        config.apply_env_overrides_with_env(&only);
18329        assert_eq!(
18330            config.deploy.expect("materialized").bake.max_p99_ms,
18331            Some(250)
18332        );
18333    }
18334
18335    #[test]
18336    fn env_override_sets_deploy_tls_enabled_and_host() {
18337        // Opt-in TLS (#1969) can be driven entirely from the environment: setting
18338        // both keys materializes `[deploy]` with TLS on and the host — the exact
18339        // precondition under which the CLI resolves `tls_host == Some(host)`.
18340        let env = MockEnv::new()
18341            .with("AUTUMN_DEPLOY__TLS__ENABLED", "true")
18342            .with("AUTUMN_DEPLOY__TLS__HOST", "app.example.com");
18343        let mut config = AutumnConfig::default();
18344        assert!(config.deploy.is_none());
18345        config.apply_env_overrides_with_env(&env);
18346        let deploy = config.deploy.expect("env should materialize deploy");
18347        assert!(deploy.tls.enabled);
18348        assert_eq!(deploy.tls.host.as_deref(), Some("app.example.com"));
18349    }
18350
18351    #[test]
18352    fn env_override_wins_over_toml_deploy_tls_host() {
18353        // TOML configures a TLS host...
18354        let mut config: AutumnConfig = toml::from_str(
18355            r#"
18356            [deploy]
18357            host = "203.0.113.10"
18358
18359            [deploy.tls]
18360            enabled = true
18361            host = "toml.example.com"
18362            "#,
18363        )
18364        .unwrap();
18365        assert_eq!(
18366            config.deploy.as_ref().unwrap().tls.host.as_deref(),
18367            Some("toml.example.com"),
18368        );
18369
18370        // ...and the env var overrides it, matching every other deploy override.
18371        let env = MockEnv::new().with("AUTUMN_DEPLOY__TLS__HOST", "env.example.com");
18372        config.apply_env_overrides_with_env(&env);
18373        let deploy = config.deploy.unwrap();
18374        assert!(deploy.tls.enabled);
18375        assert_eq!(deploy.tls.host.as_deref(), Some("env.example.com"));
18376    }
18377
18378    #[test]
18379    fn deploy_profile_defaults_to_production() {
18380        // A bare `[deploy]` table (or one omitting `profile`) resolves to the
18381        // production profile so a deploy never silently runs the `dev` profile.
18382        let config: AutumnConfig = toml::from_str(
18383            r#"
18384            [deploy]
18385            host = "203.0.113.10"
18386            "#,
18387        )
18388        .unwrap();
18389        let deploy = config.deploy.expect("deploy configured");
18390        assert_eq!(deploy.profile, "prod");
18391        // The type default matches the serde default.
18392        assert_eq!(DeployConfig::default().profile, "prod");
18393    }
18394
18395    #[test]
18396    fn deploy_profile_honors_toml_and_env_override() {
18397        // TOML sets a non-prod profile...
18398        let mut config: AutumnConfig = toml::from_str(
18399            r#"
18400            [deploy]
18401            host = "toml-host"
18402            profile = "staging"
18403            "#,
18404        )
18405        .unwrap();
18406        assert_eq!(config.deploy.as_ref().unwrap().profile, "staging");
18407
18408        // ...and `AUTUMN_DEPLOY__PROFILE` wins over the TOML value.
18409        let env = MockEnv::new().with("AUTUMN_DEPLOY__PROFILE", "prod");
18410        config.apply_env_overrides_with_env(&env);
18411        assert_eq!(config.deploy.unwrap().profile, "prod");
18412    }
18413
18414    #[test]
18415    fn env_override_materializes_deploy_from_single_host() {
18416        // Setting only AUTUMN_DEPLOY__HOST with no [deploy] in TOML seeds the
18417        // section with defaults and fills in the host.
18418        let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "198.51.100.7");
18419        let mut config = AutumnConfig::default();
18420        assert!(config.deploy.is_none());
18421        config.apply_env_overrides_with_env(&env);
18422        let deploy = config.deploy.expect("env should materialize deploy");
18423        assert_eq!(deploy.host.as_deref(), Some("198.51.100.7"));
18424        // Remaining fields fall back to their documented defaults.
18425        assert_eq!(deploy.user, "root");
18426        assert_eq!(deploy.ssh_port, 22);
18427        assert_eq!(deploy.readiness_timeout_secs, 60);
18428        assert_eq!(deploy.keep_releases, 3);
18429    }
18430
18431    #[test]
18432    fn env_override_updates_existing_deploy_host() {
18433        // An env var overrides just the host of a TOML-configured section,
18434        // leaving the other keys intact.
18435        let mut config: AutumnConfig = toml::from_str(
18436            r#"
18437            [deploy]
18438            host = "toml-host"
18439            user = "deploy"
18440            ssh_port = 2200
18441            "#,
18442        )
18443        .unwrap();
18444        let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env-host");
18445        config.apply_env_overrides_with_env(&env);
18446        let deploy = config.deploy.expect("deploy configured");
18447        assert_eq!(deploy.host.as_deref(), Some("env-host"));
18448        assert_eq!(deploy.user, "deploy");
18449        assert_eq!(deploy.ssh_port, 2200);
18450    }
18451
18452    #[test]
18453    fn env_override_parses_deploy_ssh_port_u16() {
18454        let env = MockEnv::new()
18455            .with("AUTUMN_DEPLOY__HOST", "example.com")
18456            .with("AUTUMN_DEPLOY__SSH_PORT", "65535");
18457        let mut config = AutumnConfig::default();
18458        config.apply_env_overrides_with_env(&env);
18459        let deploy = config.deploy.expect("env should materialize deploy");
18460        assert_eq!(deploy.ssh_port, 65_535_u16);
18461    }
18462
18463    #[test]
18464    fn no_deploy_env_leaves_deploy_none() {
18465        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
18466        let mut config = AutumnConfig::default();
18467        config.apply_env_overrides_with_env(&env);
18468        assert!(config.deploy.is_none());
18469    }
18470
18471    // ── deploy fleet hosts (#1621) ────────────────────────────────
18472
18473    #[test]
18474    fn deploy_hosts_list_parses_in_declaration_order_and_defaults_to_empty() {
18475        // #1621 (AC-1): `[deploy] hosts` is the fleet spelling of the target
18476        // list. Declaration order IS the rollout order, so parsing must preserve
18477        // it verbatim, and a `[deploy]` table without the key stays empty so
18478        // every pre-#1621 config is unchanged.
18479        let config: AutumnConfig = toml::from_str(
18480            r#"
18481            [deploy]
18482            hosts = ["web-1.example.com", "web-2.example.com", "web-3.example.com"]
18483            "#,
18484        )
18485        .expect("[deploy] hosts list should parse");
18486        let deploy = config.deploy.expect("deploy configured");
18487        assert_eq!(
18488            deploy.hosts,
18489            vec![
18490                "web-1.example.com".to_owned(),
18491                "web-2.example.com".to_owned(),
18492                "web-3.example.com".to_owned(),
18493            ],
18494            "hosts must parse in declaration order (the documented rollout order), got: {:?}",
18495            deploy.hosts
18496        );
18497        assert_eq!(
18498            deploy.host, None,
18499            "the fleet spelling must not populate the legacy scalar, got: {:?}",
18500            deploy.host
18501        );
18502
18503        let bare: AutumnConfig =
18504            toml::from_str("[deploy]\nhost = \"203.0.113.10\"\n").expect("legacy table parses");
18505        let bare_deploy = bare.deploy.expect("deploy configured");
18506        assert!(
18507            bare_deploy.hosts.is_empty(),
18508            "a pre-#1621 [deploy] table must resolve to an empty hosts list, got: {:?}",
18509            bare_deploy.hosts
18510        );
18511        assert!(
18512            DeployConfig::default().hosts.is_empty(),
18513            "the hand-written Default impl must seed an empty hosts list, got: {:?}",
18514            DeployConfig::default().hosts
18515        );
18516    }
18517
18518    #[test]
18519    fn deploy_validate_rejects_host_and_hosts_set_together() {
18520        // #1621 (AC-1): `host` and `hosts` are mutually exclusive — with both set
18521        // there is no unambiguous rollout order. `DeployConfig::validate` is not
18522        // wired into `AutumnConfig::validate` (the CLI's `ResolvedFleet::resolve`
18523        // is the enforcing seam), but it must MIRROR the same rules so the two
18524        // never disagree about what a valid `[deploy]` table looks like.
18525        let cfg = DeployConfig {
18526            host: Some("203.0.113.10".to_owned()),
18527            hosts: vec!["web-1.example.com".to_owned()],
18528            ..DeployConfig::default()
18529        };
18530        let err = cfg
18531            .validate()
18532            .expect_err("host + hosts together must be rejected");
18533        assert!(
18534            err.contains("[deploy] host") && err.contains("[deploy] hosts"),
18535            "the mutual-exclusion error must name BOTH keys so the operator knows \
18536             which one to delete, got: {err}"
18537        );
18538    }
18539
18540    #[test]
18541    fn deploy_validate_rejects_blank_and_duplicate_hosts_entries() {
18542        // #1621 (AC-1): a blank entry is a typo that would otherwise resolve to a
18543        // hostless SSH target mid-rollout, and a duplicate entry would deploy the
18544        // same machine twice (the second pass sees its own new release as live and
18545        // corrupts the blue/green previous-release chain).
18546        let blank = DeployConfig {
18547            hosts: vec!["web-1.example.com".to_owned(), "   ".to_owned()],
18548            ..DeployConfig::default()
18549        };
18550        let blank_err = blank
18551            .validate()
18552            .expect_err("a blank hosts entry must be rejected");
18553        assert!(
18554            blank_err.contains("hosts") && blank_err.contains('1'),
18555            "the blank-entry error must name `hosts` and the 0-based index, got: {blank_err}"
18556        );
18557
18558        let duplicate = DeployConfig {
18559            hosts: vec![
18560                "web-1.example.com".to_owned(),
18561                " web-1.example.com ".to_owned(),
18562            ],
18563            ..DeployConfig::default()
18564        };
18565        let duplicate_err = duplicate
18566            .validate()
18567            .expect_err("a duplicate hosts entry must be rejected");
18568        assert!(
18569            duplicate_err.contains("web-1.example.com"),
18570            "the duplicate error must name the repeated value, got: {duplicate_err}"
18571        );
18572
18573        let ok = DeployConfig {
18574            hosts: vec![
18575                "web-1.example.com".to_owned(),
18576                "web-2.example.com".to_owned(),
18577            ],
18578            ..DeployConfig::default()
18579        };
18580        assert!(
18581            ok.validate().is_ok(),
18582            "a well-formed fleet list must validate, got: {:?}",
18583            ok.validate()
18584        );
18585    }
18586
18587    #[test]
18588    fn env_override_materializes_deploy_from_hosts_only() {
18589        // #1621: `KEYS` in `apply_deploy_env_overrides` is a PRESENCE PROBE that
18590        // gates materialisation of the ENTIRE `[deploy]` table. If
18591        // `AUTUMN_DEPLOY__HOSTS` is missing from that array, an env-only fleet
18592        // config produces no deploy section at all — a silent skip, not an error,
18593        // in both `AutumnConfig::load` and `autumn doctor`. This is the trap test.
18594        let env = MockEnv::new().with(
18595            "AUTUMN_DEPLOY__HOSTS",
18596            "web-1.example.com,web-2.example.com",
18597        );
18598        let mut config = AutumnConfig::default();
18599        assert!(config.deploy.is_none());
18600        config.apply_env_overrides_with_env(&env);
18601        let deploy = config
18602            .deploy
18603            .expect("AUTUMN_DEPLOY__HOSTS alone must materialize the [deploy] table");
18604        assert_eq!(
18605            deploy.hosts,
18606            vec![
18607                "web-1.example.com".to_owned(),
18608                "web-2.example.com".to_owned(),
18609            ],
18610            "the CSV env value must parse into the ordered fleet list, got: {:?}",
18611            deploy.hosts
18612        );
18613        // Every other key falls back to its documented default.
18614        assert_eq!(deploy.host, None, "got: {:?}", deploy.host);
18615        assert_eq!(deploy.user, "root");
18616        assert_eq!(deploy.ssh_port, 22);
18617    }
18618
18619    #[test]
18620    fn env_override_hosts_replaces_the_whole_toml_list() {
18621        // #1621: `AUTUMN_DEPLOY__HOSTS` is a fleet-level RETARGET — it replaces
18622        // the TOML list wholesale rather than appending, matching every other
18623        // `AUTUMN_DEPLOY__*` override (env wins).
18624        let mut config: AutumnConfig = toml::from_str(
18625            r#"
18626            [deploy]
18627            hosts = ["toml-1", "toml-2", "toml-3"]
18628            "#,
18629        )
18630        .expect("[deploy] hosts list should parse");
18631        let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "env-1, env-2");
18632        config.apply_env_overrides_with_env(&env);
18633        let deploy = config.deploy.expect("deploy configured");
18634        assert_eq!(
18635            deploy.hosts,
18636            vec!["env-1".to_owned(), "env-2".to_owned()],
18637            "env must REPLACE the TOML fleet list (and trim each entry), got: {:?}",
18638            deploy.hosts
18639        );
18640    }
18641
18642    #[test]
18643    fn an_empty_deploy_hosts_env_override_behaves_as_unset() {
18644        // #1621 review finding 13. `AUTUMN_DEPLOY__HOSTS=` is the shape a CI or
18645        // compose env template produces for "fill in for a fleet" — exactly what
18646        // the sibling `AUTUMN_DEPLOY__HOST=` harmlessly takes. Splitting it
18647        // unconditionally yielded `[""]`, a NON-empty fleet list holding a blank
18648        // string, so a project keeping `[deploy] host` in autumn.toml hard-failed
18649        // every `autumn deploy` subcommand (and `autumn doctor`) with "`[deploy]
18650        // host` and `[deploy] hosts` are mutually exclusive" — naming a key the
18651        // operator never set.
18652        for blank in ["", "   ", ",", " , "] {
18653            let mut config: AutumnConfig = toml::from_str(
18654                r#"
18655                [deploy]
18656                host = "203.0.113.10"
18657                "#,
18658            )
18659            .expect("[deploy] host should parse");
18660            let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank);
18661            config.apply_env_overrides_with_env(&env);
18662            let deploy = config.deploy.expect("deploy configured");
18663            assert!(
18664                deploy.hosts.is_empty(),
18665                "AUTUMN_DEPLOY__HOSTS={blank:?} must behave as unset, got: {:?}",
18666                deploy.hosts
18667            );
18668            assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18669            assert!(
18670                deploy.validate().is_ok(),
18671                "a blank fleet override must not trip the mutual-exclusion refusal, got: {:?}",
18672                deploy.validate()
18673            );
18674        }
18675    }
18676
18677    #[test]
18678    fn a_trailing_comma_in_the_deploy_hosts_env_override_is_tolerated() {
18679        // #1621 review finding 13. Generated env lists routinely carry a trailing
18680        // (or doubled) comma; the blank segment it produces used to reach the CLI
18681        // as a blank fleet entry and refuse the whole command. Blank segments are
18682        // dropped, so the list is exactly the addresses the operator wrote.
18683        for (value, expected) in [
18684            ("10.0.0.1,10.0.0.2,", vec!["10.0.0.1", "10.0.0.2"]),
18685            ("10.0.0.1,,10.0.0.2", vec!["10.0.0.1", "10.0.0.2"]),
18686            (" 10.0.0.1 , 10.0.0.2 , ", vec!["10.0.0.1", "10.0.0.2"]),
18687        ] {
18688            let mut config = AutumnConfig::default();
18689            let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", value);
18690            config.apply_env_overrides_with_env(&env);
18691            let deploy = config.deploy.expect("deploy configured");
18692            assert_eq!(
18693                deploy.hosts,
18694                expected
18695                    .iter()
18696                    .map(|h| (*h).to_owned())
18697                    .collect::<Vec<String>>(),
18698                "AUTUMN_DEPLOY__HOSTS={value:?} must drop blank segments, got: {:?}",
18699                deploy.hosts
18700            );
18701            assert!(
18702                deploy.validate().is_ok(),
18703                "a tolerated trailing comma must not refuse the fleet, got: {:?}",
18704                deploy.validate()
18705            );
18706        }
18707    }
18708
18709    #[test]
18710    fn a_non_empty_deploy_host_env_override_clears_the_toml_alternate_spelling() {
18711        // #1621 review round 1 (Codex 1). `host` and `hosts` are MUTUALLY
18712        // EXCLUSIVE downstream, so an env override that sets one spelling while the
18713        // TOML still holds the other produced a config that refuses every `autumn
18714        // deploy` subcommand — even though `AUTUMN_DEPLOY__*` is documented to WIN
18715        // over TOML. Retargeting a `[deploy] host` project as a fleet with
18716        // `AUTUMN_DEPLOY__HOSTS=a,b` (and the reverse) must simply work.
18717        let mut fleet_over_scalar: AutumnConfig = toml::from_str(
18718            r#"
18719            [deploy]
18720            host = "203.0.113.10"
18721            "#,
18722        )
18723        .expect("[deploy] host should parse");
18724        fleet_over_scalar.apply_env_overrides_with_env(
18725            &MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "web-1,web-2"),
18726        );
18727        let deploy = fleet_over_scalar.deploy.expect("deploy configured");
18728        assert_eq!(
18729            deploy.hosts,
18730            vec!["web-1".to_owned(), "web-2".to_owned()],
18731            "the env fleet list must win, got: {:?}",
18732            deploy.hosts
18733        );
18734        assert_eq!(
18735            deploy.host, None,
18736            "a non-empty AUTUMN_DEPLOY__HOSTS must CLEAR the TOML `host`, got: {:?}",
18737            deploy.host
18738        );
18739        assert!(
18740            deploy.validate().is_ok(),
18741            "env-over-TOML retarget must not trip mutual exclusion, got: {:?}",
18742            deploy.validate()
18743        );
18744
18745        let mut scalar_over_fleet: AutumnConfig = toml::from_str(
18746            r#"
18747            [deploy]
18748            hosts = ["toml-1", "toml-2"]
18749            "#,
18750        )
18751        .expect("[deploy] hosts should parse");
18752        scalar_over_fleet
18753            .apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", "single-1"));
18754        let deploy = scalar_over_fleet.deploy.expect("deploy configured");
18755        assert_eq!(deploy.host.as_deref(), Some("single-1"));
18756        assert!(
18757            deploy.hosts.is_empty(),
18758            "a non-empty AUTUMN_DEPLOY__HOST must CLEAR the TOML `hosts`, got: {:?}",
18759            deploy.hosts
18760        );
18761        assert!(
18762            deploy.validate().is_ok(),
18763            "env-over-TOML narrowing must not trip mutual exclusion, got: {:?}",
18764            deploy.validate()
18765        );
18766    }
18767
18768    #[test]
18769    fn two_conflicting_non_empty_deploy_host_env_overrides_are_still_refused() {
18770        // #1621 review round 1 (Codex 1), the other half: env-over-TOML precedence
18771        // is NOT a licence to pick a winner between two conflicting env vars. Both
18772        // spellings set NON-EMPTY in the environment is a genuine operator error —
18773        // the rollout order is ambiguous — so both survive and the downstream
18774        // mutual-exclusion refusal still fires.
18775        for toml_src in [
18776            "[deploy]\n",
18777            "[deploy]\nhost = \"toml-host\"\n",
18778            "[deploy]\nhosts = [\"toml-1\"]\n",
18779        ] {
18780            let mut config: AutumnConfig =
18781                toml::from_str(toml_src).expect("[deploy] table should parse");
18782            config.apply_env_overrides_with_env(
18783                &MockEnv::new()
18784                    .with("AUTUMN_DEPLOY__HOST", "env-single")
18785                    .with("AUTUMN_DEPLOY__HOSTS", "env-1,env-2"),
18786            );
18787            let deploy = config.deploy.expect("deploy configured");
18788            assert_eq!(
18789                deploy.host.as_deref(),
18790                Some("env-single"),
18791                "both env spellings must survive so the conflict is visible ({toml_src:?})"
18792            );
18793            assert_eq!(
18794                deploy.hosts,
18795                vec!["env-1".to_owned(), "env-2".to_owned()],
18796                "both env spellings must survive so the conflict is visible ({toml_src:?})"
18797            );
18798            assert!(
18799                deploy.validate().is_err(),
18800                "two conflicting NON-EMPTY env spellings must still be refused ({toml_src:?})",
18801            );
18802        }
18803    }
18804
18805    #[test]
18806    fn an_empty_deploy_host_env_override_never_clears_the_toml_alternate_spelling() {
18807        // #1621 review round 1 (Codex 1). The established empty-means-unset
18808        // semantics must survive the clearing rule above: `AUTUMN_DEPLOY__HOST=`
18809        // and `AUTUMN_DEPLOY__HOSTS=` (the shape a CI/compose template emits for an
18810        // unfilled slot) say NOTHING about the alternate spelling, so the TOML value
18811        // stands.
18812        for blank in ["", "   ", ",", " , "] {
18813            let mut config: AutumnConfig = toml::from_str(
18814                r#"
18815                [deploy]
18816                host = "203.0.113.10"
18817                "#,
18818            )
18819            .expect("[deploy] host should parse");
18820            config
18821                .apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank));
18822            let deploy = config.deploy.expect("deploy configured");
18823            assert_eq!(
18824                deploy.host.as_deref(),
18825                Some("203.0.113.10"),
18826                "AUTUMN_DEPLOY__HOSTS={blank:?} must not clear the TOML `host`"
18827            );
18828            assert!(deploy.hosts.is_empty());
18829        }
18830
18831        for blank in ["", "   "] {
18832            let mut config: AutumnConfig = toml::from_str(
18833                r#"
18834                [deploy]
18835                hosts = ["toml-1", "toml-2"]
18836                "#,
18837            )
18838            .expect("[deploy] hosts should parse");
18839            config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", blank));
18840            let deploy = config.deploy.expect("deploy configured");
18841            assert_eq!(
18842                deploy.hosts,
18843                vec!["toml-1".to_owned(), "toml-2".to_owned()],
18844                "AUTUMN_DEPLOY__HOST={blank:?} must not clear the TOML `hosts`"
18845            );
18846        }
18847        // `AUTUMN_DEPLOY__HOST=` (truly empty) keeps its own documented
18848        // clears-the-scalar meaning — the clearing rule above changes nothing here.
18849        let mut config = AutumnConfig {
18850            deploy: Some(DeployConfig {
18851                host: Some("toml-host".to_owned()),
18852                ..DeployConfig::default()
18853            }),
18854            ..AutumnConfig::default()
18855        };
18856        config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", ""));
18857        assert_eq!(config.deploy.expect("deploy configured").host, None);
18858    }
18859
18860    // ── server.tls.client_auth (#1640) ────────────────────────────
18861
18862    #[test]
18863    fn client_auth_absent_is_none_and_off() {
18864        let config: AutumnConfig = toml::from_str(
18865            r#"
18866            [server.tls]
18867            cert_path = "cert.pem"
18868            key_path = "key.pem"
18869            "#,
18870        )
18871        .expect("server-only TLS should parse");
18872        let tls = config.server.tls.expect("tls configured");
18873        // Absent section => no client auth at all, so #1603's path is unchanged.
18874        assert!(tls.client_auth.is_none());
18875        assert!(!tls.client_auth_active());
18876        assert!(tls.validate().is_ok());
18877    }
18878
18879    #[test]
18880    fn client_auth_parses_modes_and_paths() {
18881        let config: AutumnConfig = toml::from_str(
18882            r#"
18883            [server.tls]
18884            cert_path = "cert.pem"
18885            key_path = "key.pem"
18886
18887            [server.tls.client_auth]
18888            mode = "required"
18889            ca_bundle_path = "/etc/autumn/tls/client-ca.pem"
18890            crl_path = "/etc/autumn/tls/client-ca.crl.pem"
18891            required_paths = ["/internal/"]
18892            "#,
18893        )
18894        .expect("[server.tls.client_auth] should parse");
18895        let tls = config.server.tls.expect("tls configured");
18896        let ca = tls.client_auth.as_ref().expect("client_auth configured");
18897        assert_eq!(ca.mode, ClientAuthMode::Required);
18898        assert_eq!(
18899            ca.ca_bundle_path,
18900            Some(PathBuf::from("/etc/autumn/tls/client-ca.pem"))
18901        );
18902        assert_eq!(
18903            ca.crl_path,
18904            Some(PathBuf::from("/etc/autumn/tls/client-ca.crl.pem"))
18905        );
18906        assert_eq!(ca.required_paths, vec!["/internal/".to_owned()]);
18907        assert_eq!(ca.reload_interval_secs, 60);
18908        assert!(tls.client_auth_active());
18909        assert!(tls.validate().is_ok());
18910    }
18911
18912    #[test]
18913    fn client_auth_mode_defaults_to_off() {
18914        let config: AutumnConfig = toml::from_str(
18915            r#"
18916            [server.tls]
18917            cert_path = "cert.pem"
18918            key_path = "key.pem"
18919
18920            [server.tls.client_auth]
18921            "#,
18922        )
18923        .expect("an empty [server.tls.client_auth] should parse");
18924        let tls = config.server.tls.expect("tls configured");
18925        let ca = tls.client_auth.as_ref().expect("client_auth present");
18926        assert_eq!(ca.mode, ClientAuthMode::Off);
18927        // Present but off is still no handshake change.
18928        assert!(!tls.client_auth_active());
18929        assert!(tls.validate().is_ok());
18930    }
18931
18932    #[test]
18933    fn client_auth_requires_a_ca_bundle_when_enabled() {
18934        let config: AutumnConfig = toml::from_str(
18935            r#"
18936            [server.tls]
18937            cert_path = "cert.pem"
18938            key_path = "key.pem"
18939
18940            [server.tls.client_auth]
18941            mode = "optional"
18942            "#,
18943        )
18944        .expect("parse");
18945        let err = config
18946            .server
18947            .tls
18948            .expect("tls configured")
18949            .validate()
18950            .expect_err("client auth without a trust store is invalid");
18951        assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
18952    }
18953
18954    #[test]
18955    fn client_auth_rejects_blank_paths() {
18956        let ca = ClientAuthConfig {
18957            mode: ClientAuthMode::Required,
18958            ca_bundle_path: Some(PathBuf::new()),
18959            ..ClientAuthConfig::default()
18960        };
18961        let err = ca
18962            .validate()
18963            .expect_err("a blank ca_bundle_path is invalid");
18964        assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
18965
18966        let ca = ClientAuthConfig {
18967            mode: ClientAuthMode::Required,
18968            ca_bundle_path: Some(PathBuf::from("ca.pem")),
18969            crl_path: Some(PathBuf::new()),
18970            ..ClientAuthConfig::default()
18971        };
18972        let err = ca.validate().expect_err("a blank crl_path is invalid");
18973        assert!(err.contains("crl_path"), "unhelpful message: {err}");
18974    }
18975
18976    #[test]
18977    fn client_auth_rejects_required_paths_when_mode_is_off() {
18978        // Routes declaring an mTLS requirement on a listener that never
18979        // requests a certificate would reject every request forever — that is a
18980        // misconfiguration, not a posture.
18981        let ca = ClientAuthConfig {
18982            required_paths: vec!["/internal/".to_owned()],
18983            ..ClientAuthConfig::default()
18984        };
18985        let err = ca
18986            .validate()
18987            .expect_err("required_paths with mode = off is invalid");
18988        assert!(err.contains("required_paths"), "unhelpful message: {err}");
18989    }
18990
18991    #[test]
18992    fn client_auth_rejects_relative_required_paths() {
18993        let ca = ClientAuthConfig {
18994            mode: ClientAuthMode::Optional,
18995            ca_bundle_path: Some(PathBuf::from("ca.pem")),
18996            required_paths: vec!["internal/".to_owned()],
18997            ..ClientAuthConfig::default()
18998        };
18999        let err = ca.validate().expect_err("a required_path must be rooted");
19000        assert!(err.contains("required_paths"), "unhelpful message: {err}");
19001    }
19002
19003    #[test]
19004    fn client_auth_rejects_noncanonical_required_paths() {
19005        // Requests are normalized before matching but the configured prefix is
19006        // not, so `/internal//` would protect strictly less than it looks like
19007        // it does. A prefix that protects less than the operator wrote is the
19008        // failure this section exists to prevent.
19009        for bad in [
19010            "/internal//",
19011            "//internal",
19012            "/internal/./keys",
19013            "/internal/../admin",
19014        ] {
19015            let ca = ClientAuthConfig {
19016                mode: ClientAuthMode::Required,
19017                ca_bundle_path: Some(PathBuf::from("ca.pem")),
19018                required_paths: vec![bad.to_owned()],
19019                ..ClientAuthConfig::default()
19020            };
19021            let Err(err) = ca.validate() else {
19022                panic!("`{bad}` should be rejected");
19023            };
19024            assert!(err.contains("required_paths"), "unhelpful message: {err}");
19025        }
19026
19027        // The canonical spellings stay accepted.
19028        for good in ["/internal", "/internal/", "/internal/keys", "/"] {
19029            let ca = ClientAuthConfig {
19030                mode: ClientAuthMode::Required,
19031                ca_bundle_path: Some(PathBuf::from("ca.pem")),
19032                required_paths: vec![good.to_owned()],
19033                ..ClientAuthConfig::default()
19034            };
19035            assert!(ca.validate().is_ok(), "`{good}` should be accepted");
19036        }
19037    }
19038
19039    #[test]
19040    fn client_auth_mode_requires_a_certificate_only_when_required() {
19041        assert!(!ClientAuthMode::Off.requests_certificate());
19042        assert!(ClientAuthMode::Optional.requests_certificate());
19043        assert!(ClientAuthMode::Required.requests_certificate());
19044        assert!(!ClientAuthMode::Optional.mandates_certificate());
19045        assert!(ClientAuthMode::Required.mandates_certificate());
19046    }
19047
19048    // ── server.tls.acme (#1608) ───────────────────────────────────
19049
19050    fn tls_static(cert: Option<&str>, key: Option<&str>) -> TlsConfig {
19051        TlsConfig {
19052            cert_path: cert.map(PathBuf::from),
19053            key_path: key.map(PathBuf::from),
19054            reload_interval_secs: default_tls_reload_interval_secs(),
19055            handshake_timeout_secs: default_tls_handshake_timeout_secs(),
19056            acme: None,
19057            client_auth: None,
19058        }
19059    }
19060
19061    fn acme_cfg(domains: &[&str], email: &str) -> AcmeConfig {
19062        AcmeConfig {
19063            domains: domains.iter().map(|d| (*d).to_owned()).collect(),
19064            contact_email: email.to_owned(),
19065            directory: AcmeDirectory::Staging,
19066            cache_dir: default_acme_cache_dir(),
19067            http_challenge_port: default_acme_http_challenge_port(),
19068            renew_before_days: default_acme_renew_before_days(),
19069            ca_root_path: None,
19070            dns: None,
19071            custom_domains: None,
19072        }
19073    }
19074
19075    #[test]
19076    fn acme_parses_from_toml_with_defaults() {
19077        let config: AutumnConfig = toml::from_str(
19078            r#"
19079            [server.tls.acme]
19080            domains = ["app.example.com"]
19081            contact_email = "ops@example.com"
19082            "#,
19083        )
19084        .expect("config with [server.tls.acme] should parse");
19085        let tls = config.server.tls.expect("tls configured");
19086        let acme = tls.acme.as_ref().expect("acme configured");
19087        assert_eq!(acme.domains, vec!["app.example.com".to_owned()]);
19088        assert_eq!(acme.contact_email, "ops@example.com");
19089        // Staging is the default on purpose (rate-limit safety).
19090        assert_eq!(acme.directory, AcmeDirectory::Staging);
19091        assert_eq!(acme.cache_dir, PathBuf::from("config/acme"));
19092        assert_eq!(acme.http_challenge_port, 80);
19093        assert_eq!(acme.renew_before_days, 30);
19094        assert!(tls.validate().is_ok());
19095    }
19096
19097    #[test]
19098    fn acme_ca_root_path_defaults_to_unset_and_round_trips() {
19099        // Unset is the default: Let's Encrypt's staging and production API
19100        // endpoints are publicly trusted, so no extra root is needed.
19101        let acme = acme_cfg(&["app.example.com"], "ops@example.com");
19102        assert_eq!(acme.ca_root_path, None);
19103        assert!(acme.validate().is_ok());
19104
19105        let config: AutumnConfig = toml::from_str(
19106            r#"
19107            [server.tls.acme]
19108            domains = ["app.example.com"]
19109            contact_email = "ops@example.com"
19110            directory = { custom = { url = "https://pebble.test/dir" } }
19111            ca_root_path = "config/pebble-root.pem"
19112            "#,
19113        )
19114        .expect("ca_root_path should parse");
19115        let acme = config.server.tls.unwrap().acme.unwrap();
19116        assert_eq!(
19117            acme.ca_root_path,
19118            Some(PathBuf::from("config/pebble-root.pem"))
19119        );
19120        assert!(acme.validate().is_ok());
19121    }
19122
19123    #[test]
19124    fn acme_rejects_a_blank_ca_root_path() {
19125        // An empty path would otherwise be carried all the way into the renewal
19126        // loop and fail every order at the TLS handshake with a far less
19127        // actionable message.
19128        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19129        acme.ca_root_path = Some(PathBuf::new());
19130        let err = acme
19131            .validate()
19132            .expect_err("a blank ca_root_path is invalid");
19133        assert!(err.contains("ca_root_path"), "unhelpful message: {err}");
19134    }
19135
19136    #[test]
19137    fn acme_directory_custom_parses() {
19138        let config: AutumnConfig = toml::from_str(
19139            r#"
19140            [server.tls.acme]
19141            domains = ["a.example.com"]
19142            contact_email = "ops@example.com"
19143            directory = { custom = { url = "https://pebble.test/dir" } }
19144            "#,
19145        )
19146        .expect("custom directory should parse");
19147        let acme = config.server.tls.unwrap().acme.unwrap();
19148        assert_eq!(
19149            acme.directory,
19150            AcmeDirectory::Custom {
19151                url: "https://pebble.test/dir".to_owned()
19152            }
19153        );
19154    }
19155
19156    #[test]
19157    fn validate_static_only_ok() {
19158        assert!(tls_static(Some("c.pem"), Some("k.pem")).validate().is_ok());
19159    }
19160
19161    #[test]
19162    fn validate_acme_only_ok() {
19163        let mut cfg = tls_static(None, None);
19164        cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19165        assert!(cfg.validate().is_ok());
19166    }
19167
19168    #[test]
19169    fn validate_both_static_and_acme_rejected() {
19170        let mut cfg = tls_static(Some("c.pem"), Some("k.pem"));
19171        cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19172        let err = cfg.validate().unwrap_err();
19173        assert!(err.contains("choose exactly one"), "got: {err}");
19174    }
19175
19176    #[test]
19177    fn validate_neither_static_nor_acme_rejected() {
19178        let err = tls_static(None, None).validate().unwrap_err();
19179        assert!(err.contains("exactly one of"), "got: {err}");
19180    }
19181
19182    #[test]
19183    fn validate_cert_without_key_rejected() {
19184        let err = tls_static(Some("c.pem"), None).validate().unwrap_err();
19185        assert!(err.contains("set together"), "got: {err}");
19186    }
19187
19188    #[test]
19189    fn validate_acme_empty_domains_rejected() {
19190        let mut cfg = tls_static(None, None);
19191        cfg.acme = Some(acme_cfg(&[], "ops@example.com"));
19192        let err = cfg.validate().unwrap_err();
19193        assert!(err.contains("at least one domain"), "got: {err}");
19194    }
19195
19196    #[test]
19197    fn validate_acme_empty_email_rejected() {
19198        let mut cfg = tls_static(None, None);
19199        cfg.acme = Some(acme_cfg(&["app.example.com"], "  "));
19200        let err = cfg.validate().unwrap_err();
19201        assert!(err.contains("contact_email"), "got: {err}");
19202    }
19203
19204    #[test]
19205    fn validate_acme_wildcard_domain_without_dns_provider_rejected() {
19206        let mut cfg = tls_static(None, None);
19207        cfg.acme = Some(acme_cfg(&["*.example.com"], "ops@example.com"));
19208        let err = cfg.validate().unwrap_err();
19209        assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
19210        assert!(err.contains("wildcard"), "got: {err}");
19211    }
19212
19213    // Regression (#1608, Codex P2): a blank/whitespace-only domain entry passes
19214    // `domains.is_empty()` (the list has length 1) but the runtime then orders a
19215    // cert for an empty DNS identifier, so `validate()` must reject it up front.
19216    #[test]
19217    fn validate_acme_blank_domain_entry_rejected() {
19218        let mut cfg = tls_static(None, None);
19219        cfg.acme = Some(acme_cfg(&[""], "ops@example.com"));
19220        let err = cfg.validate().unwrap_err();
19221        assert!(err.contains("blank entries"), "got: {err}");
19222
19223        // A whitespace-only entry is rejected the same way.
19224        let mut cfg = tls_static(None, None);
19225        cfg.acme = Some(acme_cfg(&["   "], "ops@example.com"));
19226        let err = cfg.validate().unwrap_err();
19227        assert!(err.contains("blank entries"), "got: {err}");
19228    }
19229
19230    // The `autumn doctor` grader FAILs a malformed `http_challenge_port` /
19231    // `renew_before_days` (#1608, #1874) on the premise that the runtime's TYPED
19232    // deserialization rejects the same spellings before boot. Pin that premise
19233    // here: if a future lenient `deserialize_with` ever made the runtime accept
19234    // `"30"`, doctor would start FAILing a file that boots fine — the same parity
19235    // bug in the opposite direction, and today nothing would catch it.
19236    #[test]
19237    fn acme_numeric_fields_reject_non_integer_toml_values() {
19238        for key in ["http_challenge_port", "renew_before_days"] {
19239            for bad in ["\"30\"", "30.5", "true", "-1", "4294967296"] {
19240                let src = format!(
19241                    "[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
19242                     contact_email = \"ops@example.com\"\n{key} = {bad}\n"
19243                );
19244                assert!(
19245                    toml::from_str::<AutumnConfig>(&src).is_err(),
19246                    "{key} = {bad} must fail to deserialize"
19247                );
19248            }
19249            // ... while the plain unquoted integer the doctor grader treats as
19250            // valid really is accepted.
19251            let src = format!(
19252                "[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
19253                 contact_email = \"ops@example.com\"\n{key} = 45\n"
19254            );
19255            assert!(
19256                toml::from_str::<AutumnConfig>(&src).is_ok(),
19257                "{key} = 45 must deserialize"
19258            );
19259        }
19260    }
19261
19262    // Regression (#1874): a whitespace-padded domain (`" app.example.com "`)
19263    // passed `validate()` — the blank and wildcard checks look at `domain.trim()`
19264    // but the UNTRIMMED value is what is stored, so the padded string reached the
19265    // placeholder/CSR builder and the ACME `Identifier::Dns` order. The CA then
19266    // rejects (or mis-issues) an identifier that is not the hostname the operator
19267    // meant, with a far less actionable error than a boot-time rejection.
19268    #[test]
19269    fn validate_acme_whitespace_padded_domain_rejected() {
19270        for padded in [
19271            " app.example.com",
19272            "app.example.com ",
19273            "\tapp.example.com\n",
19274        ] {
19275            let mut cfg = tls_static(None, None);
19276            cfg.acme = Some(acme_cfg(&[padded], "ops@example.com"));
19277            let err = cfg
19278                .validate()
19279                .expect_err("a whitespace-padded domain must be rejected");
19280            assert!(
19281                err.contains("whitespace"),
19282                "message must name the problem: {err}"
19283            );
19284            assert!(
19285                err.contains(&format!("`{padded}`")),
19286                "message must echo the RAW padded entry, not only the trimmed \
19287                 spelling it suggests: {err}"
19288            );
19289            assert!(
19290                err.contains("`app.example.com`"),
19291                "message must name the trimmed spelling to use: {err}"
19292            );
19293        }
19294
19295        // A padded entry is rejected even when a well-formed one precedes it, and
19296        // the message points at the right index.
19297        let mut cfg = tls_static(None, None);
19298        cfg.acme = Some(acme_cfg(
19299            &["app.example.com", " www.example.com "],
19300            "ops@example.com",
19301        ));
19302        let err = cfg.validate().unwrap_err();
19303        assert!(
19304            err.contains("index 1"),
19305            "message must name the index: {err}"
19306        );
19307
19308        // The already-trimmed spelling of the same name still passes.
19309        let mut cfg = tls_static(None, None);
19310        cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19311        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19312    }
19313
19314    // The padded-domain rule must not shadow the two more specific per-entry
19315    // rules: a whitespace-only entry is still reported as blank, and a padded
19316    // wildcard is still reported as a wildcard (the deeper problem, and the
19317    // message operators already get today).
19318    #[test]
19319    fn validate_acme_padded_domain_rule_does_not_shadow_blank_or_wildcard() {
19320        let mut cfg = tls_static(None, None);
19321        cfg.acme = Some(acme_cfg(&["   "], "ops@example.com"));
19322        let err = cfg.validate().unwrap_err();
19323        assert!(err.contains("blank entries"), "got: {err}");
19324
19325        let mut cfg = tls_static(None, None);
19326        cfg.acme = Some(acme_cfg(&[" *.example.com "], "ops@example.com"));
19327        let err = cfg.validate().unwrap_err();
19328        assert!(err.contains("wildcard"), "got: {err}");
19329        assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
19330    }
19331
19332    // Regression (#1608, Codex P2): `http_challenge_port = 0` binds an ephemeral
19333    // OS port the HTTP-01 validator (always port 80) can never reach, so every
19334    // issuance fails while the process stays up — `validate()` must reject it.
19335    #[test]
19336    fn validate_acme_zero_http_challenge_port_rejected() {
19337        let mut cfg = tls_static(None, None);
19338        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19339        acme.http_challenge_port = 0;
19340        cfg.acme = Some(acme);
19341        let err = cfg.validate().unwrap_err();
19342        assert!(err.contains("http_challenge_port"), "got: {err}");
19343    }
19344
19345    // Regression (#1608, Codex P2): a `renew_before_days` >= the issued cert's
19346    // lifetime (treated as ~90 days for a public CA) keeps `needs_renewal` true
19347    // immediately after every successful renewal, so the hourly loop re-orders a
19348    // fresh cert every tick until the CA rate-limits the account. `validate()`
19349    // must reject any value >= 90.
19350    #[test]
19351    fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
19352        // Well above the cert lifetime (the reviewer's example).
19353        let mut cfg = tls_static(None, None);
19354        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19355        acme.renew_before_days = 100;
19356        cfg.acme = Some(acme);
19357        let err = cfg.validate().unwrap_err();
19358        assert!(err.contains("renew_before_days"), "got: {err}");
19359        assert!(err.contains("rate limits"), "got: {err}");
19360
19361        // Exactly 90 (== the effective max cert lifetime) is also rejected: the
19362        // fresh cert would be due for renewal from the moment it is issued.
19363        let mut cfg = tls_static(None, None);
19364        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19365        acme.renew_before_days = 90;
19366        cfg.acme = Some(acme);
19367        assert!(
19368            cfg.validate().is_err(),
19369            "renew_before_days == 90 must be rejected"
19370        );
19371
19372        // A sane sub-lifetime value passes.
19373        let mut cfg = tls_static(None, None);
19374        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19375        acme.renew_before_days = 30;
19376        cfg.acme = Some(acme);
19377        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19378
19379        // The just-below-boundary value (89) is still accepted.
19380        let mut cfg = tls_static(None, None);
19381        let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19382        acme.renew_before_days = 89;
19383        cfg.acme = Some(acme);
19384        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19385    }
19386
19387    // ── DNS-01 / wildcard config surface (issue #1620) ───────────────────────
19388
19389    fn acme_dns_cfg(provider: AcmeDnsProvider) -> AcmeDnsConfig {
19390        AcmeDnsConfig {
19391            provider,
19392            credential: default_acme_dns_credential(),
19393            propagation_timeout_secs: default_acme_dns_propagation_timeout_secs(),
19394            poll_interval_secs: default_acme_dns_poll_interval_secs(),
19395            resolvers: default_acme_dns_resolvers(),
19396            command: Vec::new(),
19397        }
19398    }
19399
19400    #[test]
19401    fn acme_dns_section_parses_with_defaults() {
19402        let config: AutumnConfig = toml::from_str(
19403            r#"
19404            [server.tls.acme]
19405            domains = ["myapp.com", "*.myapp.com"]
19406            contact_email = "ops@myapp.com"
19407
19408            [server.tls.acme.dns]
19409            provider = "cloudflare"
19410            "#,
19411        )
19412        .expect("[server.tls.acme.dns] should parse");
19413        let tls = config.server.tls.expect("tls configured");
19414        let acme = tls.acme.as_ref().expect("acme configured");
19415        let dns = acme.dns.as_ref().expect("dns configured");
19416        assert_eq!(dns.provider, AcmeDnsProvider::Cloudflare);
19417        // The credential is a NAME in the encrypted credentials store, never a token.
19418        assert_eq!(dns.credential, "acme_dns");
19419        assert_eq!(dns.propagation_timeout_secs, 300);
19420        assert_eq!(dns.poll_interval_secs, 5);
19421        assert!(!dns.resolvers.is_empty());
19422        assert!(tls.validate().is_ok(), "got: {:?}", tls.validate());
19423    }
19424
19425    #[test]
19426    fn acme_dns_provider_names_round_trip() {
19427        for (spelling, expected) in [
19428            ("cloudflare", AcmeDnsProvider::Cloudflare),
19429            ("route53", AcmeDnsProvider::Route53),
19430            ("exec", AcmeDnsProvider::Exec),
19431        ] {
19432            let extra = if expected == AcmeDnsProvider::Exec {
19433                "\ncommand = [\"/usr/local/bin/dns-hook\"]"
19434            } else {
19435                ""
19436            };
19437            let src = format!(
19438                "[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
19439                 contact_email = \"ops@myapp.com\"\n\
19440                 [server.tls.acme.dns]\nprovider = \"{spelling}\"{extra}\n"
19441            );
19442            let config: AutumnConfig =
19443                toml::from_str(&src).unwrap_or_else(|e| panic!("{spelling} should parse: {e}"));
19444            let acme = config.server.tls.unwrap().acme.unwrap();
19445            assert_eq!(acme.dns.as_ref().unwrap().provider, expected);
19446            assert!(acme.validate().is_ok(), "{spelling}: {:?}", acme.validate());
19447        }
19448    }
19449
19450    // AC3: DNS provider API tokens are NEVER supplied in plaintext `autumn.toml`.
19451    // The section has no field to hold one, and `deny_unknown_fields` turns an
19452    // operator's attempt into a load-time error rather than a silently-ignored key
19453    // that leaves them wondering why the token "did not work".
19454    #[test]
19455    fn acme_dns_section_rejects_an_inline_secret() {
19456        for secret_key in [
19457            "api_token",
19458            "token",
19459            "secret_access_key",
19460            "access_key_id",
19461            "api_key",
19462        ] {
19463            let src = format!(
19464                "[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
19465                 contact_email = \"ops@myapp.com\"\n\
19466                 [server.tls.acme.dns]\nprovider = \"cloudflare\"\n{secret_key} = \"s3cret\"\n"
19467            );
19468            let err = toml::from_str::<AutumnConfig>(&src)
19469                .err()
19470                .unwrap_or_else(|| panic!("{secret_key} in autumn.toml must be rejected"));
19471            let msg = err.to_string();
19472            assert!(
19473                msg.contains(secret_key),
19474                "the rejection must name the offending key: {msg}"
19475            );
19476        }
19477    }
19478
19479    // AC1: a wildcard domain is accepted once — and only once — a DNS-01 provider
19480    // is configured, because only DNS-01 can validate a wildcard identifier.
19481    #[test]
19482    fn validate_acme_wildcard_requires_a_dns_provider() {
19483        let mut cfg = tls_static(None, None);
19484        cfg.acme = Some(acme_cfg(&["*.myapp.com"], "ops@myapp.com"));
19485        let err = cfg
19486            .validate()
19487            .expect_err("a wildcard without [server.tls.acme.dns] must be rejected");
19488        assert!(err.contains("wildcard"), "got: {err}");
19489        assert!(
19490            err.contains("[server.tls.acme.dns]"),
19491            "the message must name the section that fixes it: {err}"
19492        );
19493        assert!(err.contains("DNS-01"), "got: {err}");
19494
19495        // With the section present it is accepted.
19496        let mut cfg = tls_static(None, None);
19497        let mut acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
19498        acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19499        cfg.acme = Some(acme);
19500        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19501    }
19502
19503    // A malformed wildcard never reaches the CA as an opaque rejection.
19504    #[test]
19505    fn validate_acme_rejects_malformed_wildcards() {
19506        for bad in ["*", "*.", "*myapp.com", "app.*.myapp.com", "*.*.myapp.com"] {
19507            let mut cfg = tls_static(None, None);
19508            let mut acme = acme_cfg(&[bad], "ops@myapp.com");
19509            acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19510            cfg.acme = Some(acme);
19511            let err = cfg.validate().expect_err(&format!(
19512                "`{bad}` is not a usable wildcard and must be rejected"
19513            ));
19514            assert!(
19515                err.contains(bad),
19516                "the message must echo the offending entry `{bad}`: {err}"
19517            );
19518        }
19519
19520        // The one well-formed shape stays accepted.
19521        let mut cfg = tls_static(None, None);
19522        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19523        acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19524        cfg.acme = Some(acme);
19525        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19526    }
19527
19528    #[test]
19529    fn validate_acme_dns_exec_requires_a_command() {
19530        let mut cfg = tls_static(None, None);
19531        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19532        acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Exec));
19533        cfg.acme = Some(acme);
19534        let err = cfg
19535            .validate()
19536            .expect_err("provider = \"exec\" with no command must be rejected");
19537        assert!(err.contains("command"), "got: {err}");
19538
19539        // …and a command on a non-exec provider is a misconfiguration too: it
19540        // would be silently ignored while the operator believes it runs.
19541        let mut cfg = tls_static(None, None);
19542        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19543        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19544        dns.command = vec!["/usr/local/bin/dns-hook".to_owned()];
19545        acme.dns = Some(dns);
19546        cfg.acme = Some(acme);
19547        let err = cfg
19548            .validate()
19549            .expect_err("command on cloudflare is invalid");
19550        assert!(err.contains("command"), "got: {err}");
19551    }
19552
19553    #[test]
19554    fn validate_acme_dns_rejects_unusable_propagation_bounds() {
19555        // A zero timeout would fail every order before a record could ever appear.
19556        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19557        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19558        dns.propagation_timeout_secs = 0;
19559        acme.dns = Some(dns);
19560        let err = acme.validate().expect_err("zero timeout is invalid");
19561        assert!(err.contains("propagation_timeout_secs"), "got: {err}");
19562
19563        // A zero poll interval would busy-loop the resolver.
19564        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19565        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19566        dns.poll_interval_secs = 0;
19567        acme.dns = Some(dns);
19568        let err = acme.validate().expect_err("zero poll interval is invalid");
19569        assert!(err.contains("poll_interval_secs"), "got: {err}");
19570
19571        // A poll interval longer than the whole budget means exactly one probe.
19572        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19573        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19574        dns.poll_interval_secs = 600;
19575        dns.propagation_timeout_secs = 60;
19576        acme.dns = Some(dns);
19577        let err = acme.validate().expect_err("interval > timeout is invalid");
19578        assert!(err.contains("poll_interval_secs"), "got: {err}");
19579    }
19580
19581    // An unbounded budget reaches `Instant::now() + Duration::from_secs(..)`,
19582    // which PANICS on overflow — inside the spawned renewal task, where the
19583    // panic kills the loop silently and the placeholder is served forever.
19584    #[test]
19585    fn validate_acme_dns_rejects_an_unbounded_propagation_timeout() {
19586        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19587        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19588        dns.propagation_timeout_secs = u64::MAX;
19589        acme.dns = Some(dns);
19590        let err = acme
19591            .validate()
19592            .expect_err("an unbounded propagation budget is invalid");
19593        assert!(err.contains("propagation_timeout_secs"), "got: {err}");
19594
19595        // The documented ceiling itself is accepted.
19596        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19597        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19598        dns.propagation_timeout_secs = MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS;
19599        acme.dns = Some(dns);
19600        assert!(acme.validate().is_ok(), "got: {:?}", acme.validate());
19601    }
19602
19603    // `command[0]` is handed to the OS verbatim, so a blank or padded program
19604    // would fail at order time with an opaque ENOENT rather than at boot.
19605    #[test]
19606    fn validate_acme_dns_rejects_an_unusable_exec_program() {
19607        for bad in [
19608            vec![String::new()],
19609            vec!["   ".to_owned(), "arg".to_owned()],
19610        ] {
19611            let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19612            let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
19613            dns.command = bad.clone();
19614            acme.dns = Some(dns);
19615            let err = acme
19616                .validate()
19617                .expect_err(&format!("`{bad:?}` is not a usable exec command"));
19618            assert!(err.contains("command"), "got: {err}");
19619        }
19620
19621        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19622        let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
19623        dns.command = vec![" /usr/local/bin/hook ".to_owned()];
19624        acme.dns = Some(dns);
19625        let err = acme.validate().expect_err("a padded program is invalid");
19626        assert!(err.contains("whitespace"), "got: {err}");
19627    }
19628
19629    #[test]
19630    fn validate_acme_dns_rejects_a_blank_or_unparseable_resolver() {
19631        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19632        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19633        dns.resolvers = Vec::new();
19634        acme.dns = Some(dns);
19635        let err = acme.validate().expect_err("no resolvers is invalid");
19636        assert!(err.contains("resolvers"), "got: {err}");
19637
19638        let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19639        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19640        dns.resolvers = vec!["not a resolver".to_owned()];
19641        acme.dns = Some(dns);
19642        let err = acme.validate().expect_err("garbage resolver is invalid");
19643        assert!(err.contains("resolvers"), "got: {err}");
19644    }
19645
19646    #[test]
19647    fn acme_dns_resolver_addresses_accept_bare_ips_and_explicit_ports() {
19648        let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19649        dns.resolvers = vec!["1.1.1.1".to_owned(), "9.9.9.9:5353".to_owned()];
19650        let addrs = dns.resolver_addrs().expect("resolvers parse");
19651        assert_eq!(addrs[0].port(), 53, "a bare IP defaults to port 53");
19652        assert_eq!(addrs[1].port(), 5353);
19653    }
19654
19655    #[test]
19656    fn validate_acme_dns_rejects_a_blank_credential_reference() {
19657        for blank in ["", "   "] {
19658            let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19659            let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19660            dns.credential = blank.to_owned();
19661            acme.dns = Some(dns);
19662            let err = acme.validate().expect_err("a blank credential is invalid");
19663            assert!(err.contains("credential"), "got: {err}");
19664        }
19665    }
19666
19667    // AC7 support: the grader `autumn doctor` uses to tell an operator that their
19668    // `tenancy.base_domain` is not covered by the certificate.
19669    #[test]
19670    fn san_covers_host_matches_rfc6125_wildcard_rules() {
19671        // Exact match.
19672        assert!(san_covers_host("myapp.com", "myapp.com"));
19673        assert!(
19674            san_covers_host("MyApp.COM", "myapp.com"),
19675            "case-insensitive"
19676        );
19677        assert!(!san_covers_host("myapp.com", "tenant1.myapp.com"));
19678
19679        // A wildcard covers exactly ONE label.
19680        assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com"));
19681        assert!(
19682            !san_covers_host("*.myapp.com", "a.b.myapp.com"),
19683            "a wildcard must not span a dot"
19684        );
19685        assert!(
19686            !san_covers_host("*.myapp.com", "myapp.com"),
19687            "a wildcard does not cover the apex"
19688        );
19689        assert!(!san_covers_host("*.myapp.com", "myapp.com.evil.com"));
19690        assert!(!san_covers_host("*.myapp.com", ""));
19691
19692        // A trailing dot on the queried host is the same name.
19693        assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com."));
19694    }
19695
19696    #[test]
19697    fn acme_config_covers_host_uses_every_san() {
19698        let acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
19699        assert!(acme.covers_host("myapp.com"));
19700        assert!(acme.covers_host("tenant42.myapp.com"));
19701        assert!(!acme.covers_host("other.example.com"));
19702        assert!(!acme.covers_host("deep.tenant42.myapp.com"));
19703    }
19704
19705    // Companion: a valid domain list plus the default challenge port is unaffected
19706    // by the new blank-entry / zero-port rejections.
19707    #[test]
19708    fn validate_acme_valid_domains_and_port_ok() {
19709        let mut cfg = tls_static(None, None);
19710        cfg.acme = Some(acme_cfg(
19711            &["app.example.com", "www.example.com"],
19712            "ops@example.com",
19713        ));
19714        assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19715    }
19716
19717    // ── server.capacity_contract (#1733) ───────────────────────────────
19718
19719    #[test]
19720    fn server_config_defaults_capacity_contract_none() {
19721        let config = AutumnConfig::default();
19722        assert!(config.server.capacity_contract.is_none());
19723    }
19724
19725    #[test]
19726    fn capacity_contract_parses_from_toml() {
19727        let config: AutumnConfig = toml::from_str(
19728            r#"
19729            [server]
19730            capacity_contract = "capacity.lock"
19731            "#,
19732        )
19733        .expect("config with server.capacity_contract should parse");
19734        assert_eq!(
19735            config.server.capacity_contract.as_deref(),
19736            Some("capacity.lock")
19737        );
19738    }
19739
19740    #[test]
19741    fn env_override_server_capacity_contract() {
19742        let env = MockEnv::new().with("AUTUMN_SERVER__CAPACITY_CONTRACT", "deploy/capacity.lock");
19743        let mut config = AutumnConfig::default();
19744        config.apply_env_overrides_with_env(&env);
19745        assert_eq!(
19746            config.server.capacity_contract.as_deref(),
19747            Some("deploy/capacity.lock")
19748        );
19749    }
19750
19751    // ── server.admission (#3068) ─────────────────────────────────
19752
19753    #[test]
19754    fn admission_defaults_change_nothing() {
19755        let a = AutumnConfig::default().server.admission;
19756        assert_eq!(a.mode, AdmissionMode::Static);
19757        assert_eq!(a.algorithm, AdmissionAlgorithm::Gradient2);
19758        assert!(!a.trust_criticality_header);
19759        assert_eq!(
19760            a.partition_shares().unwrap(),
19761            crate::admission::PartitionShares::default()
19762        );
19763    }
19764
19765    #[test]
19766    fn admission_parses_from_toml() {
19767        let config: AutumnConfig = toml::from_str(
19768            r#"
19769            [server.admission]
19770            mode = "adaptive"
19771            algorithm = "vegas"
19772            min_limit = 4
19773            max_limit = 400
19774            initial_limit = 40
19775            latency_threshold_ms = 250
19776            trust_criticality_header = true
19777            [server.admission.partitions]
19778            default = 0.9
19779            sheddable = 0.4
19780            "#,
19781        )
19782        .expect("server.admission should parse");
19783        let a = &config.server.admission;
19784        assert_eq!(a.mode, AdmissionMode::Adaptive);
19785        assert_eq!(a.algorithm, AdmissionAlgorithm::Vegas);
19786        assert_eq!(
19787            (a.min_limit, a.max_limit, a.initial_limit),
19788            (4, Some(400), 40)
19789        );
19790        assert_eq!(a.latency_threshold_ms, 250);
19791        assert!(a.trust_criticality_header);
19792        let shares = a.partition_shares().unwrap();
19793        assert_eq!(
19794            shares.threshold(crate::admission::Criticality::Default, 100),
19795            90
19796        );
19797        assert_eq!(
19798            shares.threshold(crate::admission::Criticality::Sheddable, 100),
19799            40
19800        );
19801        config.validate().expect("valid admission config");
19802    }
19803
19804    #[test]
19805    fn admission_rejects_an_unknown_algorithm() {
19806        let err = toml::from_str::<AutumnConfig>(
19807            r#"
19808            [server.admission]
19809            algorithm = "hystrix"
19810            "#,
19811        );
19812        assert!(err.is_err());
19813    }
19814
19815    #[test]
19816    fn admission_env_overrides() {
19817        let env = MockEnv::new()
19818            .with("AUTUMN_SERVER__ADMISSION__MODE", "adaptive")
19819            .with("AUTUMN_SERVER__ADMISSION__ALGORITHM", "aimd")
19820            .with("AUTUMN_SERVER__ADMISSION__MIN_LIMIT", "2")
19821            .with("AUTUMN_SERVER__ADMISSION__MAX_LIMIT", "50")
19822            .with("AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT", "10")
19823            .with("AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS", "99")
19824            .with("AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER", "true")
19825            .with("AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT", "0.8")
19826            .with("AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE", "0.2");
19827        let mut config = AutumnConfig::default();
19828        config.apply_env_overrides_with_env(&env);
19829        let a = &config.server.admission;
19830        assert_eq!(a.mode, AdmissionMode::Adaptive);
19831        assert_eq!(a.algorithm, AdmissionAlgorithm::Aimd);
19832        assert_eq!(
19833            (a.min_limit, a.max_limit, a.initial_limit),
19834            (2, Some(50), 10)
19835        );
19836        assert_eq!(a.latency_threshold_ms, 99);
19837        assert!(a.trust_criticality_header);
19838        assert!((a.partitions.default - 0.8).abs() < f64::EPSILON);
19839        assert!((a.partitions.sheddable - 0.2).abs() < f64::EPSILON);
19840    }
19841
19842    #[cfg(feature = "http-client")]
19843    #[test]
19844    fn adaptive_throttle_parses_and_validates() {
19845        let config: AutumnConfig = toml::from_str(
19846            r"
19847            [http.client.adaptive_throttle]
19848            enabled = true
19849            k = 1.5
19850            window_secs = 60
19851            ",
19852        )
19853        .expect("adaptive_throttle should parse");
19854        let t = config.http.client.adaptive_throttle;
19855        assert!(t.enabled);
19856        assert!((t.k - 1.5).abs() < f64::EPSILON);
19857        assert_eq!(t.window_secs, 60);
19858        config.validate().expect("valid");
19859        assert!(
19860            !AutumnConfig::default()
19861                .http
19862                .client
19863                .adaptive_throttle
19864                .enabled
19865        );
19866
19867        let mut bad = AutumnConfig::default();
19868        bad.http.client.adaptive_throttle.k = 0.5;
19869        assert!(bad.validate().is_err(), "k < 1");
19870        let mut bad = AutumnConfig::default();
19871        bad.http.client.adaptive_throttle.window_secs = 0;
19872        assert!(bad.validate().is_err(), "window 0");
19873    }
19874
19875    #[test]
19876    fn admission_validate_rejects_bad_bounds_and_shares() {
19877        let mut config = AutumnConfig::default();
19878        config.server.admission.min_limit = 0;
19879        assert!(config.validate().is_err(), "min_limit = 0");
19880
19881        let mut config = AutumnConfig::default();
19882        config.server.admission.min_limit = 10;
19883        config.server.admission.max_limit = Some(5);
19884        assert!(config.validate().is_err(), "min_limit > max_limit");
19885
19886        let mut config = AutumnConfig::default();
19887        config.server.admission.partitions.sheddable = 0.9;
19888        config.server.admission.partitions.default = 0.5;
19889        assert!(config.validate().is_err(), "sheddable > default");
19890    }
19891
19892    // ── server.max_concurrent_requests (#1006) ────────────────────
19893
19894    #[test]
19895    fn server_config_defaults_max_concurrent_requests_none() {
19896        // Default must preserve today's unlimited behavior — no existing app
19897        // silently changes throughput.
19898        let config = AutumnConfig::default();
19899        assert!(config.server.max_concurrent_requests.is_none());
19900    }
19901
19902    #[test]
19903    fn max_concurrent_requests_parses_from_toml() {
19904        let config: AutumnConfig = toml::from_str(
19905            r"
19906            [server]
19907            max_concurrent_requests = 64
19908            ",
19909        )
19910        .expect("config with server.max_concurrent_requests should parse");
19911        assert_eq!(config.server.max_concurrent_requests, Some(64));
19912    }
19913
19914    #[test]
19915    fn env_override_server_max_concurrent_requests() {
19916        let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "128");
19917        let mut config = AutumnConfig::default();
19918        config.apply_env_overrides_with_env(&env);
19919        assert_eq!(config.server.max_concurrent_requests, Some(128));
19920    }
19921
19922    #[test]
19923    fn env_override_invalid_max_concurrent_requests_ignored() {
19924        let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "not_a_number");
19925        let mut config = AutumnConfig::default();
19926        config.apply_env_overrides_with_env(&env);
19927        assert!(config.server.max_concurrent_requests.is_none());
19928    }
19929
19930    #[test]
19931    fn env_override_empty_max_concurrent_requests_clears_to_none() {
19932        // parse_env_option's documented convention: empty string clears to None.
19933        let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "");
19934        let mut config = AutumnConfig::default();
19935        config.server.max_concurrent_requests = Some(64);
19936        config.apply_env_overrides_with_env(&env);
19937        assert!(config.server.max_concurrent_requests.is_none());
19938    }
19939
19940    // ── Log env override tests ───────────────────────────────────
19941
19942    #[test]
19943    fn env_override_log_level() {
19944        let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
19945        let mut config = AutumnConfig::default();
19946        config.apply_env_overrides_with_env(&env);
19947        assert_eq!(config.log.level, "debug");
19948    }
19949
19950    #[test]
19951    fn env_override_log_format_json() {
19952        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
19953        let mut config = AutumnConfig::default();
19954        config.apply_env_overrides_with_env(&env);
19955        assert_eq!(config.log.format, LogFormat::Json);
19956    }
19957
19958    #[test]
19959    fn env_override_log_format_pretty() {
19960        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
19961        let mut config = AutumnConfig::default();
19962        config.apply_env_overrides_with_env(&env);
19963        assert_eq!(config.log.format, LogFormat::Pretty);
19964    }
19965
19966    #[test]
19967    fn env_override_invalid_log_format_ignored() {
19968        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
19969        let mut config = AutumnConfig::default();
19970        config.apply_env_overrides_with_env(&env);
19971        assert_eq!(config.log.format, LogFormat::Auto);
19972    }
19973
19974    // ── Health env override tests ────────────────────────────────
19975
19976    #[test]
19977    fn env_override_telemetry_fields() {
19978        let env = MockEnv::new()
19979            .with("AUTUMN_TELEMETRY__ENABLED", "true")
19980            .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
19981            .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
19982            .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
19983            .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
19984            .with(
19985                "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
19986                "http://otel-collector:4317",
19987            )
19988            .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
19989            .with("AUTUMN_TELEMETRY__STRICT", "true");
19990        let mut config = AutumnConfig::default();
19991        config.apply_env_overrides_with_env(&env);
19992        assert!(config.telemetry.enabled);
19993        assert_eq!(config.telemetry.service_name, "orders-api");
19994        assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
19995        assert_eq!(config.telemetry.service_version, "1.2.3");
19996        assert_eq!(config.telemetry.environment, "production");
19997        assert_eq!(
19998            config.telemetry.otlp_endpoint.as_deref(),
19999            Some("http://otel-collector:4317")
20000        );
20001        assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
20002        assert!(config.telemetry.strict);
20003    }
20004
20005    #[test]
20006    fn env_override_telemetry_sample_ratio() {
20007        let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "0.25");
20008        let mut config = AutumnConfig::default();
20009        config.apply_env_overrides_with_env(&env);
20010        assert!((config.telemetry.sample_ratio - 0.25).abs() < f64::EPSILON);
20011
20012        let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "half");
20013        let mut config = AutumnConfig::default();
20014        config.apply_env_overrides_with_env(&env);
20015        assert!((config.telemetry.sample_ratio - 1.0).abs() < f64::EPSILON);
20016    }
20017
20018    #[test]
20019    fn env_override_invalid_telemetry_protocol_ignored() {
20020        let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
20021        let mut config = AutumnConfig::default();
20022        config.apply_env_overrides_with_env(&env);
20023        assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
20024    }
20025
20026    #[test]
20027    fn env_override_health_path() {
20028        let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
20029        let mut config = AutumnConfig::default();
20030        config.apply_env_overrides_with_env(&env);
20031        assert_eq!(config.health.path, "/healthz");
20032    }
20033
20034    #[test]
20035    fn env_override_probe_paths() {
20036        let env = MockEnv::new()
20037            .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
20038            .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
20039            .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
20040        let mut config = AutumnConfig::default();
20041        config.apply_env_overrides_with_env(&env);
20042        assert_eq!(config.health.live_path, "/livez");
20043        assert_eq!(config.health.ready_path, "/readyz");
20044        assert_eq!(config.health.startup_path, "/startupz");
20045    }
20046
20047    // ── Precedence test ──────────────────────────────────────────
20048
20049    #[test]
20050    fn env_overrides_toml_values() {
20051        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
20052        let dir = tempfile::tempdir().unwrap();
20053        let path = dir.path().join("autumn.toml");
20054        std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
20055        let mut config = AutumnConfig::load_from(&path).unwrap();
20056        config.apply_env_overrides_with_env(&env);
20057        assert_eq!(config.server.port, 9999); // env wins
20058    }
20059
20060    // ── Validation tests ─────────────────────────────────────────
20061
20062    #[test]
20063    fn validate_rejects_invalid_url_scheme() {
20064        let config = DatabaseConfig {
20065            url: Some("mysql://localhost/test".to_owned()),
20066            ..Default::default()
20067        };
20068        let result = config.validate();
20069        assert!(result.is_err());
20070        assert!(
20071            result
20072                .unwrap_err()
20073                .to_string()
20074                .contains("must start with postgres://")
20075        );
20076    }
20077
20078    #[test]
20079    fn validate_accepts_postgres_url() {
20080        let config = DatabaseConfig {
20081            url: Some("postgres://localhost/test".to_owned()),
20082            ..Default::default()
20083        };
20084        assert!(config.validate().is_ok());
20085    }
20086
20087    #[test]
20088    fn validate_accepts_postgresql_url() {
20089        let config = DatabaseConfig {
20090            url: Some("postgresql://localhost/test".to_owned()),
20091            ..Default::default()
20092        };
20093        assert!(config.validate().is_ok());
20094    }
20095
20096    #[test]
20097    fn validate_accepts_no_url() {
20098        let config = DatabaseConfig::default();
20099        assert!(config.validate().is_ok());
20100    }
20101
20102    #[test]
20103    fn validate_accepts_keyword_value_connection_strings() {
20104        // The pool's TLS support parses libpq keyword/value strings, so
20105        // validation must let them through (issue #1585 review) — including
20106        // quoted values and whitespace around `=`.
20107        for url in [
20108            "host=db user=app dbname=app",
20109            "host=db user=app sslmode=require",
20110            "host=db sslmode = require",
20111            "host=db password='p w' sslmode='verify-full'",
20112            "host=db password=https://looks-like-a-url sslmode=require",
20113        ] {
20114            let config = DatabaseConfig {
20115                url: Some(url.to_owned()),
20116                ..Default::default()
20117            };
20118            assert!(
20119                config.validate().is_ok(),
20120                "keyword/value string must validate: {url}"
20121            );
20122        }
20123        // primary_url and shard URLs accept the same forms.
20124        let config = DatabaseConfig {
20125            primary_url: Some("host=db user=app sslmode=require".to_owned()),
20126            ..Default::default()
20127        };
20128        assert!(config.validate().is_ok());
20129        let config = DatabaseConfig {
20130            primary_url: Some("postgres://db-control/app".to_owned()),
20131            shards: vec![ShardConfig {
20132                name: "s0".to_owned(),
20133                primary_url: "host=db-shard0 user=app dbname=app".to_owned(),
20134                replica_url: None,
20135                slots: None,
20136                primary_pool_size: None,
20137                replica_pool_size: None,
20138                replica_fallback: None,
20139            }],
20140            ..Default::default()
20141        };
20142        assert!(
20143            config.validate().is_ok(),
20144            "shard URLs accept the keyword form too: {:?}",
20145            config.validate()
20146        );
20147    }
20148
20149    #[test]
20150    fn validate_still_rejects_garbage_connection_strings() {
20151        for url in [
20152            "mysql://localhost/test",
20153            "mysql://localhost/test?a=b",
20154            "not a connection string",
20155            "localhost",
20156            "host=",
20157            "host='unterminated",
20158        ] {
20159            let config = DatabaseConfig {
20160                url: Some(url.to_owned()),
20161                ..Default::default()
20162            };
20163            let err = config
20164                .validate()
20165                .expect_err(&format!("garbage must be rejected: {url:?}"))
20166                .to_string();
20167            assert!(
20168                err.contains("must start with postgres:// or postgresql://"),
20169                "the error must stay clear about accepted forms, got: {err}"
20170            );
20171        }
20172    }
20173
20174    // ── DatabaseBackend detection tests (issue #1614) ──────────────
20175
20176    #[test]
20177    fn detect_backend_postgres_urls() {
20178        for url in [
20179            "postgres://localhost/app",
20180            "postgresql://user:pass@db:5432/app",
20181        ] {
20182            assert_eq!(
20183                DatabaseBackend::detect(url),
20184                Some(DatabaseBackend::Postgres),
20185                "{url} should detect as postgres"
20186            );
20187        }
20188    }
20189
20190    #[test]
20191    fn detect_backend_postgres_keyword_value() {
20192        // libpq keyword/value strings are a Postgres shape (the pool accepts
20193        // them), so they must classify as Postgres, not fall through.
20194        assert_eq!(
20195            DatabaseBackend::detect("host=db user=app sslmode=require"),
20196            Some(DatabaseBackend::Postgres)
20197        );
20198    }
20199
20200    #[test]
20201    fn detect_backend_sqlite_schemes() {
20202        for url in [
20203            "sqlite:///var/lib/app.db", // canonical sqlite:// (absolute path)
20204            "sqlite://./relative.db",
20205            "sqlite::memory:",
20206            "sqlite:app.db", // shorter sqlite: form
20207            "file:app.db",   // file: form
20208        ] {
20209            assert_eq!(
20210                DatabaseBackend::detect(url),
20211                Some(DatabaseBackend::Sqlite),
20212                "{url} should detect as sqlite"
20213            );
20214        }
20215    }
20216
20217    #[test]
20218    fn detect_backend_bare_path_is_unrecognized() {
20219        // A bare filesystem path carries no scheme distinguishing it from a
20220        // typo'd URL, so it is deliberately NOT auto-detected as SQLite. Users
20221        // must spell an explicit sqlite:// (or sqlite:/file:) scheme.
20222        for target in ["/var/lib/app.db", "./app.db", "app.db", "C:\\db\\app.db"] {
20223            assert_eq!(
20224                DatabaseBackend::detect(target),
20225                None,
20226                "{target} must not be auto-detected as a backend"
20227            );
20228        }
20229    }
20230
20231    #[test]
20232    fn detect_backend_garbage_is_unrecognized() {
20233        for target in ["mysql://localhost/app", "not a connection string", "host="] {
20234            assert_eq!(DatabaseBackend::detect(target), None, "{target}");
20235        }
20236    }
20237
20238    // ── SQLite config validation tests (issue #1614) ───────────────
20239
20240    #[test]
20241    fn validate_accepts_sqlite_url() {
20242        let config = DatabaseConfig {
20243            url: Some("sqlite:///var/lib/app.db".to_owned()),
20244            ..Default::default()
20245        };
20246        assert!(
20247            config.validate().is_ok(),
20248            "a sqlite:// target must be accepted as valid config: {:?}",
20249            config.validate()
20250        );
20251    }
20252
20253    #[test]
20254    fn validate_accepts_sqlite_primary_url() {
20255        let config = DatabaseConfig {
20256            primary_url: Some("sqlite::memory:".to_owned()),
20257            ..Default::default()
20258        };
20259        assert!(config.validate().is_ok(), "{:?}", config.validate());
20260    }
20261
20262    #[test]
20263    fn validate_rejects_replica_url_on_sqlite() {
20264        let config = DatabaseConfig {
20265            primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20266            replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
20267            ..Default::default()
20268        };
20269        let err = config
20270            .validate()
20271            .expect_err("read replicas must be refused on sqlite")
20272            .to_string();
20273        assert!(
20274            err.contains("read replicas require the postgres backend"),
20275            "message must name the postgres requirement, got: {err}"
20276        );
20277    }
20278
20279    #[test]
20280    fn validate_rejects_shards_on_sqlite() {
20281        let config = DatabaseConfig {
20282            primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20283            shards: vec![ShardConfig {
20284                name: "s0".to_owned(),
20285                primary_url: "postgres://db-shard0/app".to_owned(),
20286                replica_url: None,
20287                slots: None,
20288                primary_pool_size: None,
20289                replica_pool_size: None,
20290                replica_fallback: None,
20291            }],
20292            ..Default::default()
20293        };
20294        let err = config
20295            .validate()
20296            .expect_err("shards must be refused on sqlite")
20297            .to_string();
20298        assert!(
20299            err.contains("database shards require the postgres backend"),
20300            "message must name the postgres requirement, got: {err}"
20301        );
20302    }
20303
20304    fn fleet(mode: crate::fleet_layout::FleetMode) -> DatabaseFleetConfig {
20305        DatabaseFleetConfig {
20306            mode,
20307            root: "/var/lib/app/fleet".to_owned(),
20308            path: None,
20309            max_open: 16,
20310            pool_size: 2,
20311            create_on_demand: None,
20312            idle_close_secs: 300,
20313            restore_missing: false,
20314            ownership: None,
20315        }
20316    }
20317
20318    fn fleet_db(fleet_config: DatabaseFleetConfig) -> DatabaseConfig {
20319        DatabaseConfig {
20320            url: Some("sqlite:///var/lib/app/control.db".to_owned()),
20321            fleet: Some(fleet_config),
20322            ..Default::default()
20323        }
20324    }
20325
20326    #[test]
20327    #[allow(
20328        clippy::literal_string_with_formatting_args,
20329        reason = "fleet path templates use {placeholders}"
20330    )]
20331    fn validate_accepts_a_fleet_on_a_sqlite_control_database() {
20332        use crate::fleet_layout::FleetMode;
20333        fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
20334        fleet_db(fleet(FleetMode::Slot)).validate().unwrap();
20335        let tenant = fleet(FleetMode::Tenant);
20336        assert_eq!(tenant.path_template(), "{bucket}/{tenant}.db");
20337        assert!(!tenant.effective_create_on_demand());
20338        let slot = fleet(FleetMode::Slot);
20339        assert_eq!(slot.path_template(), "{bucket}/slot-{slot}.db");
20340        assert!(slot.effective_create_on_demand());
20341    }
20342
20343    #[test]
20344    fn validate_rejects_a_fleet_without_a_sqlite_control_database() {
20345        use crate::fleet_layout::FleetMode;
20346        for url in [
20347            None,
20348            Some("postgres://db/app"),
20349            // In memory: framework state would vanish with the connection.
20350            Some("sqlite::memory:"),
20351            Some("sqlite:"),
20352            Some("sqlite://"),
20353            Some("file::memory:?cache=shared"),
20354            Some("sqlite:file:app?mode=memory&cache=shared"),
20355        ] {
20356            let config = DatabaseConfig {
20357                url: url.map(str::to_owned),
20358                fleet: Some(fleet(FleetMode::Tenant)),
20359                ..Default::default()
20360            };
20361            let err = config
20362                .validate()
20363                .expect_err("needs a file-backed sqlite control database")
20364                .to_string();
20365            assert!(err.contains("sqlite: control database"), "{url:?}: {err}");
20366        }
20367    }
20368
20369    #[test]
20370    fn percent_decoding_matches_sqlite_uri_paths() {
20371        assert_eq!(percent_decode_uri_path("/a/%66leet/b"), "/a/fleet/b");
20372        assert_eq!(percent_decode_uri_path("/a%2Fb"), "/a/b");
20373        assert_eq!(percent_decode_uri_path("/50%"), "/50%", "trailing % stays");
20374        assert_eq!(percent_decode_uri_path("/x%zz"), "/x%zz", "non-hex stays");
20375        assert_eq!(
20376            percent_decode_uri_path("/x%+1"),
20377            "/x%+1",
20378            "a sign is not hex"
20379        );
20380        assert_eq!(
20381            percent_decode_uri_path("/db%00tail"),
20382            "/db",
20383            "NUL ends the path"
20384        );
20385    }
20386
20387    #[test]
20388    fn validate_rejects_a_control_database_inside_the_fleet_root() {
20389        use crate::fleet_layout::FleetMode;
20390        // A tenant named `control` under `{tenant}.db` would open the control
20391        // database itself and migrate it as a tenant.
20392        for url in [
20393            "sqlite:///var/lib/app/control.db",
20394            "sqlite:///var/lib/app/./sub/../control.db",
20395            "sqlite:/var/lib/app/control.db?mode=rwc",
20396            "sqlite:file:/var/lib/app/control.db?cache=private",
20397            "sqlite:file:///var/lib/app/control.db",
20398        ] {
20399            let mut config = fleet_db(DatabaseFleetConfig {
20400                root: "/var/lib/app".to_owned(),
20401                path: Some("{tenant}.db".to_owned()),
20402                ..fleet(FleetMode::Tenant)
20403            });
20404            config.url = Some(url.to_owned());
20405            let err = config
20406                .validate()
20407                .expect_err("control database inside the fleet root")
20408                .to_string();
20409            assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
20410        }
20411        // SQLite percent-decodes a `file:` URI path (and ends it at `?` or
20412        // `#`), so `%66leet` is `fleet`: the encoded spelling must not slip by.
20413        for url in [
20414            "file:/var/lib/app/%66leet/control.db",
20415            "sqlite:file:/var/lib/app/%66leet/control.db?mode=rwc",
20416            "sqlite:file:///var/lib/app/fleet/control.db#frag",
20417            // `localhost` is the local host's authority: an absolute path.
20418            "file://localhost/var/lib/app/fleet/control.db",
20419            "sqlite:file://LOCALHOST/var/lib/app/fleet/control.db",
20420        ] {
20421            let mut config = fleet_db(fleet(FleetMode::Tenant));
20422            config.url = Some(url.to_owned());
20423            let err = config
20424                .validate()
20425                .expect_err("decoded control path is inside the fleet root")
20426                .to_string();
20427            assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
20428        }
20429        // Outside a `file:` URI, `?` is just a filename character: SQLite opens
20430        // `/srv/fleet?data/control.db`, inside a root named `/srv/fleet?data`.
20431        let mut literal = fleet_db(DatabaseFleetConfig {
20432            root: "/srv/fleet?data".to_owned(),
20433            ..fleet(FleetMode::Tenant)
20434        });
20435        literal.url = Some("sqlite:///srv/fleet?data/control.db".to_owned());
20436        let err = literal
20437            .validate()
20438            .expect_err("a literal `?` path inside the root")
20439            .to_string();
20440        assert!(err.contains("inside database.fleet.root"), "{err}");
20441        // Beside the root (the documented layout) is fine, and so is a sibling
20442        // whose name merely starts with the root's.
20443        fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
20444        let mut sibling = fleet_db(fleet(FleetMode::Tenant));
20445        sibling.url = Some("sqlite:///var/lib/app/fleet-control.db".to_owned());
20446        sibling.validate().unwrap();
20447    }
20448
20449    #[test]
20450    fn validate_rejects_a_fleet_alongside_shards_or_the_directory_router() {
20451        use crate::fleet_layout::FleetMode;
20452        let mut config = fleet_db(fleet(FleetMode::Slot));
20453        config.directory_shard_router = true;
20454        let err = config.validate().unwrap_err().to_string();
20455        assert!(err.contains("directory_shard_router"), "{err}");
20456
20457        // Shards on sqlite are refused by the backend rule first; on a fleet
20458        // config the dedicated message must still be reachable through
20459        // `validate_fleet` itself.
20460        let mut config = fleet_db(fleet(FleetMode::Slot));
20461        config.shards = vec![shard("s0", "postgres://db-shard0/app")];
20462        assert!(config.validate().is_err());
20463        let err = config.validate_fleet().unwrap_err().to_string();
20464        assert!(err.contains("declare one or the other"), "{err}");
20465    }
20466
20467    #[test]
20468    fn validate_rejects_malformed_fleet_sections() {
20469        use crate::fleet_layout::FleetMode;
20470        type Mutate = fn(&mut DatabaseFleetConfig);
20471        let cases: [(&str, Mutate); 6] = [
20472            ("root", |f| f.root = "  ".to_owned()),
20473            ("not a database URL", |f| f.root = "sqlite:///x".to_owned()),
20474            ("{tenant}", |f| f.path = Some("{bucket}/all.db".to_owned())),
20475            ("..", |f| f.path = Some("../{tenant}.db".to_owned())),
20476            ("max_open", |f| f.max_open = 0),
20477            ("pool_size", |f| f.pool_size = 0),
20478        ];
20479        for (needle, mutate) in cases {
20480            let mut section = fleet(FleetMode::Tenant);
20481            mutate(&mut section);
20482            let err = fleet_db(section).validate().unwrap_err().to_string();
20483            assert!(err.contains(needle), "expected {needle:?} in {err}");
20484        }
20485    }
20486
20487    #[test]
20488    fn fleet_env_overrides_create_and_adjust_the_section() {
20489        use crate::fleet_layout::FleetMode;
20490        let mut config = AutumnConfig::default();
20491        // MODE without ROOT does not create a half-configured fleet.
20492        config.apply_env_overrides_with_env(
20493            &MockEnv::new().with("AUTUMN_DATABASE__FLEET__MODE", "slot"),
20494        );
20495        assert!(config.database.fleet.is_none());
20496
20497        let env = MockEnv::new()
20498            .with("AUTUMN_DATABASE__FLEET__MODE", "tenant")
20499            .with("AUTUMN_DATABASE__FLEET__ROOT", "/data/fleet")
20500            .with("AUTUMN_DATABASE__FLEET__PATH", "{tenant}/main.db")
20501            .with("AUTUMN_DATABASE__FLEET__MAX_OPEN", "9")
20502            .with("AUTUMN_DATABASE__FLEET__POOL_SIZE", "1")
20503            .with("AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND", "true")
20504            .with("AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS", "0")
20505            .with("AUTUMN_DATABASE__FLEET__RESTORE_MISSING", "true");
20506        config.apply_env_overrides_with_env(&env);
20507        let fleet = config.database.fleet.expect("env creates the fleet");
20508        assert_eq!(fleet.mode, FleetMode::Tenant);
20509        assert_eq!(fleet.root, "/data/fleet");
20510        assert_eq!(fleet.path_template(), "{tenant}/main.db");
20511        assert_eq!(fleet.max_open, 9);
20512        assert_eq!(fleet.pool_size, 1);
20513        assert!(fleet.effective_create_on_demand());
20514        assert_eq!(fleet.idle_close_secs, 0);
20515        assert!(fleet.restore_missing);
20516    }
20517
20518    #[test]
20519    fn fleet_ownership_deserializes_and_validates() {
20520        let config: AutumnConfig = toml::from_str(
20521            r#"
20522            [database]
20523            url = "sqlite:///var/lib/app/control.db"
20524
20525            [database.fleet]
20526            mode = "slot"
20527            root = "/var/lib/app/fleet"
20528
20529            [database.fleet.ownership]
20530            owner = "host-a"
20531            slots = ["0-8191", 9000]
20532            "#,
20533        )
20534        .unwrap();
20535        let ownership = config
20536            .database
20537            .fleet
20538            .as_ref()
20539            .and_then(|f| f.ownership.clone())
20540            .unwrap();
20541        assert_eq!(ownership.lease_secs, 30);
20542        assert_eq!(ownership.ranges().unwrap(), vec![(0, 8191), (9000, 9000)]);
20543        config.database.validate().unwrap();
20544
20545        let every = FleetOwnershipConfig {
20546            owner: "a".to_owned(),
20547            slots: Vec::new(),
20548            lease_secs: 30,
20549        };
20550        assert_eq!(every.ranges().unwrap(), vec![(0, SLOT_COUNT - 1)]);
20551        let bad = |owner: &str, slots: &[&str], lease_secs| {
20552            let mut fleet = fleet(crate::fleet_layout::FleetMode::Slot);
20553            fleet.ownership = Some(FleetOwnershipConfig {
20554                owner: owner.to_owned(),
20555                slots: slots
20556                    .iter()
20557                    .map(|s| SlotSpec::Range((*s).to_owned()))
20558                    .collect(),
20559                lease_secs,
20560            });
20561            fleet_db(fleet).validate().unwrap_err().to_string()
20562        };
20563        assert!(bad("", &[], 30).contains("owner"));
20564        assert!(bad("a", &["0-10", "10-20"], 30).contains("overlap"));
20565        assert!(bad("a", &["0-16384"], 30).contains("out of range"));
20566        assert!(bad("a", &["9-1"], 30).contains("inverted"));
20567        assert!(bad("a", &[], 2).contains("lease_secs"));
20568        assert!(bad("a", &[], 3601).contains("lease_secs"));
20569        assert!(bad(" a", &[], 30).contains("white space"));
20570    }
20571
20572    #[test]
20573    fn fleet_ownership_env_overrides_create_and_adjust_the_section() {
20574        let mut config = AutumnConfig::default();
20575        config.database.fleet = Some(fleet(crate::fleet_layout::FleetMode::Slot));
20576        config.apply_env_overrides_with_env(
20577            &MockEnv::new()
20578                .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER", "host-b")
20579                .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS", "0-99, 200,")
20580                .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS", "12"),
20581        );
20582        let ownership = config.database.fleet.unwrap().ownership.unwrap();
20583        assert_eq!(ownership.owner, "host-b");
20584        assert_eq!(ownership.ranges().unwrap(), vec![(0, 99), (200, 200)]);
20585        assert_eq!(ownership.lease_secs, 12);
20586    }
20587
20588    #[test]
20589    fn fleet_section_deserializes_from_toml() {
20590        use crate::fleet_layout::FleetMode;
20591        let config: AutumnConfig = toml::from_str(
20592            r#"
20593            [database]
20594            url = "sqlite:///var/lib/app/control.db"
20595
20596            [database.fleet]
20597            mode = "slot"
20598            root = "/var/lib/app/fleet"
20599            "#,
20600        )
20601        .unwrap();
20602        let fleet = config.database.fleet.as_ref().unwrap();
20603        assert_eq!(fleet.mode, FleetMode::Slot);
20604        assert_eq!(fleet.max_open, 256);
20605        assert_eq!(fleet.pool_size, 2);
20606        assert_eq!(fleet.idle_close_secs, 300);
20607        config.database.validate().unwrap();
20608    }
20609
20610    #[test]
20611    fn validate_rejects_backend_mismatch_across_roles() {
20612        // Postgres primary with a SQLite replica: a boot-time misconfiguration,
20613        // not a first-query surprise.
20614        let config = DatabaseConfig {
20615            primary_url: Some("postgres://db-primary/app".to_owned()),
20616            replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
20617            ..Default::default()
20618        };
20619        let err = config
20620            .validate()
20621            .expect_err("mixed backends must be refused")
20622            .to_string();
20623        assert!(
20624            err.contains("database.replica_url")
20625                && err.contains("does not match the primary database backend"),
20626            "message must name the offending field and the mismatch, got: {err}"
20627        );
20628    }
20629
20630    #[test]
20631    fn validate_rejects_sqlite_primary_with_postgres_url() {
20632        // effective_primary_url() prefers primary_url; the legacy `url` role
20633        // must agree on the backend.
20634        let config = DatabaseConfig {
20635            primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20636            url: Some("postgres://db-primary/app".to_owned()),
20637            ..Default::default()
20638        };
20639        let err = config
20640            .validate()
20641            .expect_err("mixed backends must be refused")
20642            .to_string();
20643        assert!(
20644            err.contains("database.url")
20645                && err.contains("does not match the primary database backend"),
20646            "got: {err}"
20647        );
20648    }
20649
20650    #[test]
20651    fn validate_postgres_app_with_replica_still_valid() {
20652        // Regression guard: the existing Postgres primary + replica path is
20653        // unchanged and still validates cleanly.
20654        let config = DatabaseConfig {
20655            primary_url: Some("postgres://db-primary/app".to_owned()),
20656            replica_url: Some("postgres://db-replica/app".to_owned()),
20657            ..Default::default()
20658        };
20659        assert!(config.validate().is_ok(), "{:?}", config.validate());
20660    }
20661
20662    // ── Profile tests ──────────────────────────────────────────
20663
20664    #[test]
20665    fn resolve_profile_from_autumn_env() {
20666        let env = MockEnv::new().with("AUTUMN_ENV", "prod");
20667        let profile = resolve_profile(&env);
20668        assert_eq!(profile, "prod");
20669    }
20670
20671    #[test]
20672    fn resolve_profile_from_legacy_env() {
20673        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
20674        let profile = resolve_profile(&env);
20675        assert_eq!(profile, "staging");
20676    }
20677
20678    #[test]
20679    fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
20680        let env = MockEnv::new()
20681            .with("AUTUMN_ENV", "dev")
20682            .with("AUTUMN_PROFILE", "prod");
20683        let profile = resolve_profile(&env);
20684        assert_eq!(profile, "dev");
20685    }
20686
20687    #[test]
20688    fn resolve_profile_normalizes_production_alias() {
20689        let env = MockEnv::new().with("AUTUMN_ENV", "production");
20690        let profile = resolve_profile(&env);
20691        assert_eq!(profile, "prod");
20692    }
20693
20694    #[test]
20695    fn resolve_profile_normalizes_development_alias_with_whitespace() {
20696        let env = MockEnv::new().with("AUTUMN_ENV", "  development  ");
20697        let profile = resolve_profile(&env);
20698        assert_eq!(profile, "dev");
20699    }
20700
20701    #[test]
20702    fn resolve_profile_normalizes_uppercase_dev_and_prod() {
20703        let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
20704        let prod = resolve_profile(&prod_env);
20705        assert_eq!(prod, "prod");
20706
20707        let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
20708        let dev = resolve_profile(&dev_env);
20709        assert_eq!(dev, "dev");
20710    }
20711
20712    #[test]
20713    fn resolve_profile_preserves_case_for_custom_profiles() {
20714        let env = MockEnv::new().with("AUTUMN_ENV", "QA");
20715        let profile = resolve_profile(&env);
20716        assert_eq!(profile, "QA");
20717    }
20718
20719    #[test]
20720    fn resolve_profile_auto_detect_debug() {
20721        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
20722        let profile = resolve_profile(&env);
20723        assert_eq!(profile, "dev");
20724    }
20725
20726    #[test]
20727    fn resolve_profile_auto_detect_release() {
20728        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
20729        let profile = resolve_profile(&env);
20730        assert_eq!(profile, "prod");
20731    }
20732
20733    #[test]
20734    fn resolve_profile_defaults_to_dev_when_no_signal_present() {
20735        let env = MockEnv::new();
20736        let profile = resolve_profile(&env);
20737        assert_eq!(profile, "dev");
20738    }
20739
20740    #[test]
20741    fn dev_profile_smart_defaults() {
20742        let defaults = profile_defaults_as_toml("dev");
20743        let toml_str = toml::to_string(&defaults).unwrap();
20744        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20745
20746        assert_eq!(config.log.level, "debug");
20747        assert_eq!(config.log.format, LogFormat::Pretty);
20748        assert_eq!(config.server.host, "127.0.0.1");
20749        assert_eq!(config.server.shutdown_timeout_secs, 1);
20750        assert_eq!(
20751            config.server.prestop_grace_secs, 0,
20752            "dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
20753        );
20754        assert_eq!(config.telemetry.environment, "development");
20755        assert!(config.health.detailed);
20756        assert_eq!(config.cors.allowed_origins, vec!["*"]);
20757        assert!(
20758            config.security.trusted_proxies.trust_forwarded_headers,
20759            "dev profile must trust forwarded headers from loopback"
20760        );
20761        assert!(
20762            config
20763                .security
20764                .trusted_proxies
20765                .ranges
20766                .contains(&"127.0.0.0/8".to_owned()),
20767            "dev profile must include 127.0.0.0/8 as trusted proxy range"
20768        );
20769        assert!(
20770            config
20771                .security
20772                .trusted_proxies
20773                .ranges
20774                .contains(&"::1/128".to_owned()),
20775            "dev profile must include ::1/128 as trusted proxy range"
20776        );
20777    }
20778
20779    #[test]
20780    fn prod_profile_smart_defaults() {
20781        let defaults = profile_defaults_as_toml("prod");
20782        let toml_str = toml::to_string(&defaults).unwrap();
20783        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20784
20785        assert_eq!(config.log.level, "info");
20786        assert_eq!(config.log.format, LogFormat::Json);
20787        assert_eq!(config.server.host, "0.0.0.0");
20788        assert_eq!(config.server.shutdown_timeout_secs, 35);
20789        assert_eq!(config.telemetry.environment, "production");
20790        assert!(!config.health.detailed);
20791        // AC: HSTS auto-enabled in the production profile.
20792        assert!(
20793            config.security.headers.strict_transport_security,
20794            "prod profile must auto-enable Strict-Transport-Security"
20795        );
20796        // Defaults should still be secure-by-default in prod.
20797        assert_eq!(config.security.headers.x_frame_options, "DENY");
20798        assert!(config.security.headers.x_content_type_options);
20799        assert!(!config.security.headers.content_security_policy.is_empty());
20800    }
20801
20802    #[test]
20803    fn profile_shutdown_defaults_match_the_smart_defaults() {
20804        assert_eq!(profile_shutdown_defaults("dev"), (0, 1));
20805        assert_eq!(profile_shutdown_defaults("development"), (0, 1));
20806        assert_eq!(profile_shutdown_defaults(" dev "), (0, 1));
20807        assert_eq!(profile_shutdown_defaults("prod"), (5, 35));
20808        assert_eq!(profile_shutdown_defaults("production"), (5, 35));
20809        let server = ServerConfig::default();
20810        assert_eq!(
20811            profile_shutdown_defaults("staging"),
20812            (server.prestop_grace_secs, server.shutdown_timeout_secs)
20813        );
20814    }
20815
20816    #[test]
20817    fn dev_profile_does_not_auto_enable_hsts() {
20818        let defaults = profile_defaults_as_toml("dev");
20819        let toml_str = toml::to_string(&defaults).unwrap();
20820        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20821
20822        assert!(
20823            !config.security.headers.strict_transport_security,
20824            "dev profile must not force HSTS on (local http development)"
20825        );
20826    }
20827
20828    #[test]
20829    fn custom_profile_no_smart_defaults() {
20830        let defaults = profile_defaults_as_toml("staging");
20831        assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
20832    }
20833
20834    #[test]
20835    fn deep_merge_tables() {
20836        let mut base: toml::Value = toml::from_str(
20837            r#"
20838            [server]
20839            port = 3000
20840            host = "127.0.0.1"
20841            [database]
20842            pool_size = 10
20843            "#,
20844        )
20845        .unwrap();
20846
20847        let overlay: toml::Value = toml::from_str(
20848            r#"
20849            [server]
20850            port = 8080
20851            [database]
20852            url = "postgres://localhost/test"
20853            "#,
20854        )
20855        .unwrap();
20856
20857        deep_merge(&mut base, overlay);
20858
20859        // Overlay value wins
20860        assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
20861        // Base value preserved when not in overlay
20862        assert_eq!(
20863            base["server"]["host"],
20864            toml::Value::String("127.0.0.1".into())
20865        );
20866        // New key from overlay added
20867        assert_eq!(
20868            base["database"]["url"],
20869            toml::Value::String("postgres://localhost/test".into())
20870        );
20871        // Base key preserved
20872        assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
20873    }
20874
20875    #[test]
20876    fn profile_toml_overrides_base_toml() {
20877        let dir = tempfile::tempdir().unwrap();
20878        let base_path = dir.path().join("autumn.toml");
20879        let dev_path = dir.path().join("autumn-dev.toml");
20880
20881        std::fs::write(
20882            &base_path,
20883            r"
20884            [server]
20885            port = 3000
20886            [database]
20887            pool_size = 10
20888            ",
20889        )
20890        .unwrap();
20891
20892        std::fs::write(
20893            &dev_path,
20894            r#"
20895            [database]
20896            url = "postgres://localhost/myapp_dev"
20897            "#,
20898        )
20899        .unwrap();
20900
20901        // Load base
20902        let mut merged = toml::Value::Table(toml::map::Map::new());
20903        let base = load_raw_toml(&base_path).unwrap().unwrap();
20904        deep_merge(&mut merged, base);
20905        let profile = load_raw_toml(&dev_path).unwrap().unwrap();
20906        deep_merge(&mut merged, profile);
20907
20908        let toml_str = toml::to_string(&merged).unwrap();
20909        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20910
20911        assert_eq!(config.server.port, 3000); // from base
20912        assert_eq!(config.database.pool_size, 10); // from base, preserved
20913        assert_eq!(
20914            config.database.url.as_deref(),
20915            Some("postgres://localhost/myapp_dev")
20916        ); // from profile
20917    }
20918
20919    #[test]
20920    fn inline_profile_section_overrides_base_toml() {
20921        let mut merged = toml::Value::Table(toml::map::Map::new());
20922        let base: toml::Value = toml::from_str(
20923            r#"
20924            [server]
20925            port = 3000
20926
20927            [log]
20928            level = "info"
20929
20930            [profile.dev.log]
20931            level = "debug"
20932            "#,
20933        )
20934        .unwrap();
20935
20936        deep_merge(&mut merged, base.clone());
20937        let inline = profile_section_from_base_toml(&base, "dev").unwrap();
20938        deep_merge(&mut merged, inline);
20939
20940        let toml_str = toml::to_string(&merged).unwrap();
20941        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20942        assert_eq!(config.server.port, 3000);
20943        assert_eq!(config.log.level, "debug");
20944    }
20945
20946    #[test]
20947    fn levenshtein_basic() {
20948        assert_eq!(levenshtein("dev", "dev"), 0);
20949        assert_eq!(levenshtein("dev", "dve"), 2); // swap = 2 edits (del + ins)
20950        assert_eq!(levenshtein("prod", "prodd"), 1);
20951        assert_eq!(levenshtein("prod", "prd"), 1);
20952        assert_eq!(levenshtein("staging", "dev"), 7);
20953    }
20954
20955    #[test]
20956    fn env_override_health_detailed() {
20957        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
20958        let mut config = AutumnConfig::default();
20959        config.apply_env_overrides_with_env(&env);
20960        assert!(config.health.detailed);
20961    }
20962
20963    #[test]
20964    fn env_overrides_health_dependency_checks() {
20965        let env = MockEnv::new()
20966            .with("AUTUMN_HEALTH__CACHE_TTL_MS", "250")
20967            .with("AUTUMN_HEALTH__PING_TIMEOUT_MS", "750")
20968            .with("AUTUMN_HEALTH__DB_READINESS", "false")
20969            .with("AUTUMN_HEALTH__REDIS_READINESS", "true");
20970        let mut config = AutumnConfig::default();
20971        config.apply_env_overrides_with_env(&env);
20972        assert_eq!(
20973            config.health.cache_ttl(),
20974            std::time::Duration::from_millis(250)
20975        );
20976        assert_eq!(
20977            config.health.ping_timeout(),
20978            std::time::Duration::from_millis(750)
20979        );
20980        assert!(!config.health.db_readiness);
20981        assert!(config.health.redis_readiness);
20982    }
20983
20984    #[test]
20985    fn health_dependency_checks_parse_from_toml() {
20986        let config: AutumnConfig = toml::from_str(
20987            "[health]\ncache_ttl_ms = 0\nping_timeout_ms = 300\ndb_readiness = false\n",
20988        )
20989        .expect("valid toml");
20990        assert_eq!(config.health.cache_ttl_ms, 0);
20991        assert_eq!(config.health.ping_timeout_ms, 300);
20992        assert!(!config.health.db_readiness);
20993        assert!(!config.health.redis_readiness);
20994    }
20995
20996    #[test]
20997    fn zero_ping_timeout_is_rejected() {
20998        let mut config = AutumnConfig::default();
20999        config.health.ping_timeout_ms = 0;
21000        let error = config.validate().expect_err("zero ping timeout");
21001        assert!(
21002            error.to_string().contains("health.ping_timeout_ms"),
21003            "{error}"
21004        );
21005    }
21006
21007    #[test]
21008    fn profile_name_accessor() {
21009        let mut config = AutumnConfig::default();
21010        assert!(config.profile_name().is_none());
21011
21012        config.profile = Some("dev".to_owned());
21013        assert_eq!(config.profile_name(), Some("dev"));
21014    }
21015
21016    // ── Mutant-hunting tests ────────────────────────────────────
21017
21018    #[test]
21019    fn find_config_file_falls_back_to_cwd() {
21020        // Without AUTUMN_MANIFEST_DIR, should return just the filename
21021        let env = MockEnv::new();
21022        let path = find_config_file_named("autumn.toml", &env);
21023        assert_eq!(path, PathBuf::from("autumn.toml"));
21024    }
21025
21026    #[test]
21027    fn find_config_file_uses_manifest_dir_when_file_exists() {
21028        let dir = tempfile::tempdir().unwrap();
21029        let config_path = dir.path().join("autumn.toml");
21030        std::fs::write(&config_path, "").unwrap();
21031
21032        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21033        let path = find_config_file_named("autumn.toml", &env);
21034        assert_eq!(path, config_path);
21035    }
21036
21037    #[test]
21038    fn find_config_file_falls_back_when_manifest_dir_missing_file() {
21039        let dir = tempfile::tempdir().unwrap();
21040        // dir exists but the file doesn't
21041        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21042        let path = find_config_file_named("nonexistent.toml", &env);
21043        assert_eq!(path, PathBuf::from("nonexistent.toml"));
21044    }
21045
21046    #[test]
21047    fn resolve_profile_cli_flag_exact_match() {
21048        // resolve_profile checks `--profile` in CLI args. We can't easily
21049        // inject args, but we can verify the env path doesn't match other args.
21050        // The `== "--profile"` guard is the key: if it were `!=`, every arg
21051        // would trigger the branch.
21052        let env = MockEnv::new();
21053        // With no env vars and no matching CLI args, should be None
21054        let profile = resolve_profile(&env);
21055        // This may or may not be None depending on test harness args,
21056        // but the important thing is it doesn't crash or return garbage.
21057        // The env-based tests above cover the positive cases.
21058        drop(profile);
21059    }
21060
21061    #[test]
21062    fn deep_merge_non_table_overlay_replaces_base() {
21063        // When overlay is not a table, it should replace (not merge into) base.
21064        // This kills the `&& → ||` mutant on line 162.
21065        let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
21066        let overlay = toml::Value::String("not_a_table".into());
21067
21068        // When base is table and overlay is NOT table, base should be unchanged
21069        // (the function only merges when BOTH are tables).
21070        deep_merge(&mut base, overlay);
21071        // base should still be the original table (overlay was ignored)
21072        assert!(base.is_table());
21073        assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
21074    }
21075
21076    #[test]
21077    fn deep_merge_when_base_not_table() {
21078        // When base is not a table, overlay should not merge
21079        let mut base = toml::Value::String("original".into());
21080        let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
21081
21082        deep_merge(&mut base, overlay);
21083        // base should be unchanged
21084        assert_eq!(base, toml::Value::String("original".into()));
21085    }
21086
21087    #[test]
21088    fn suggest_profile_close_match() {
21089        // "dve" is edit-distance 2 from "dev" → should suggest "dev"
21090        assert_eq!(suggest_profile("dve"), Some("dev"));
21091    }
21092
21093    #[test]
21094    fn suggest_profile_no_match_when_distant() {
21095        // "xyz" is far from both "dev" and "prod" → no suggestion
21096        assert_eq!(suggest_profile("xyz"), None);
21097    }
21098
21099    #[test]
21100    fn suggest_profile_exact_known_profile() {
21101        // Exact match has distance 0 → suggests itself
21102        assert_eq!(suggest_profile("dev"), Some("dev"));
21103        assert_eq!(suggest_profile("prod"), Some("prod"));
21104    }
21105
21106    #[test]
21107    fn suggest_profile_prd() {
21108        // "prd" is distance 1 from "prod"
21109        assert_eq!(suggest_profile("prd"), Some("prod"));
21110    }
21111
21112    #[test]
21113    fn warn_profile_typo_runs_without_panic() {
21114        warn_profile_typo("dve");
21115        warn_profile_typo("xyz");
21116    }
21117
21118    #[test]
21119    fn should_warn_missing_profile_file_custom_without_inline() {
21120        assert!(should_warn_missing_profile_file("staging", false));
21121    }
21122
21123    #[test]
21124    fn should_not_warn_missing_profile_file_custom_with_inline() {
21125        assert!(!should_warn_missing_profile_file("staging", true));
21126    }
21127
21128    #[test]
21129    fn should_not_warn_missing_profile_file_dev_or_prod() {
21130        assert!(!should_warn_missing_profile_file("dev", false));
21131        assert!(!should_warn_missing_profile_file("prod", false));
21132    }
21133
21134    #[test]
21135    fn levenshtein_threshold_in_warn_profile_typo() {
21136        assert!(levenshtein("dve", "dev") <= 2);
21137        assert!(levenshtein("xyz", "dev") > 2);
21138        assert!(levenshtein("xyz", "prod") > 2);
21139    }
21140
21141    #[test]
21142    fn env_override_cors_allowed_origins() {
21143        let env = MockEnv::new().with(
21144            "AUTUMN_CORS__ALLOWED_ORIGINS",
21145            "https://a.com, https://b.com",
21146        );
21147        let mut config = AutumnConfig::default();
21148        config.apply_env_overrides_with_env(&env);
21149        assert_eq!(
21150            config.cors.allowed_origins,
21151            vec!["https://a.com", "https://b.com"]
21152        );
21153    }
21154
21155    #[test]
21156    fn env_override_cors_allow_credentials() {
21157        let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
21158        let mut config = AutumnConfig::default();
21159        config.apply_env_overrides_with_env(&env);
21160        assert!(config.cors.allow_credentials);
21161    }
21162
21163    #[test]
21164    fn env_override_cors_max_age() {
21165        let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
21166        let mut config = AutumnConfig::default();
21167        config.apply_env_overrides_with_env(&env);
21168        assert_eq!(config.cors.max_age_secs, 3600);
21169    }
21170
21171    #[test]
21172    fn cors_validate_rejects_wildcard_with_credentials() {
21173        let mut config = AutumnConfig::default();
21174        config.cors.allowed_origins = vec!["*".to_owned()];
21175        config.cors.allow_credentials = true;
21176
21177        let result = config.validate();
21178        match result {
21179            Err(ConfigError::Validation(msg)) => {
21180                assert!(
21181                    msg.contains("allow_credentials") && msg.contains('*'),
21182                    "message should mention credentials and wildcard, got: {msg}"
21183                );
21184            }
21185            other => panic!("expected ConfigError::Validation, got {other:?}"),
21186        }
21187    }
21188
21189    #[test]
21190    fn cors_validate_accepts_wildcard_without_credentials() {
21191        let mut config = AutumnConfig::default();
21192        config.cors.allowed_origins = vec!["*".to_owned()];
21193        config.cors.allow_credentials = false;
21194        assert!(config.validate().is_ok());
21195    }
21196
21197    #[test]
21198    fn cors_validate_accepts_explicit_origins_with_credentials() {
21199        let mut config = AutumnConfig::default();
21200        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
21201        config.cors.allow_credentials = true;
21202        assert!(config.validate().is_ok());
21203    }
21204
21205    #[test]
21206    fn load_uses_profile_layering() {
21207        // Test AutumnConfig::load_with_env() with a dev profile via env var.
21208        // This kills the "replace load → Ok(Default::default())" mutant.
21209        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
21210
21211        let config = AutumnConfig::load_with_env(&env).unwrap();
21212        // With dev profile, smart defaults should apply
21213        assert_eq!(config.profile.as_deref(), Some("dev"));
21214        assert_eq!(config.log.level, "debug"); // dev default
21215        assert_eq!(config.log.format, LogFormat::Pretty); // dev default
21216        assert!(config.health.detailed); // dev default
21217    }
21218
21219    #[test]
21220    fn load_custom_profile_without_toml_warns() {
21221        // Test the typo warning branch: profile != "dev" && profile != "prod"
21222        // without a corresponding autumn-{profile}.toml triggers warn_profile_typo.
21223        // This kills the match guard mutants on line 341.
21224        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
21225
21226        let config = AutumnConfig::load_with_env(&env).unwrap();
21227        assert_eq!(config.profile.as_deref(), Some("staging"));
21228        // staging has no smart defaults, so values should be framework defaults
21229        assert_eq!(config.server.port, 3000);
21230        assert_eq!(config.log.level, "info");
21231    }
21232
21233    #[test]
21234    fn load_dev_profile_no_profile_toml_no_warn() {
21235        // dev/prod without their profile TOML should NOT trigger warn_profile_typo.
21236        // This tests the `None => {}` branch (line 342).
21237        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
21238
21239        let config = AutumnConfig::load_with_env(&env).unwrap();
21240        assert_eq!(config.profile.as_deref(), Some("dev"));
21241    }
21242
21243    #[test]
21244    fn load_custom_profile_uses_inline_profile_without_legacy_file() {
21245        let dir = tempfile::tempdir().unwrap();
21246        let base_path = dir.path().join("autumn.toml");
21247        std::fs::write(
21248            &base_path,
21249            r"
21250            [server]
21251            port = 3000
21252
21253            [profile.staging.server]
21254            port = 4100
21255            ",
21256        )
21257        .unwrap();
21258
21259        let env = MockEnv::new()
21260            .with("AUTUMN_ENV", "staging")
21261            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21262
21263        let config = AutumnConfig::load_with_env(&env).unwrap();
21264        assert_eq!(config.profile.as_deref(), Some("staging"));
21265        assert_eq!(config.server.port, 4100);
21266    }
21267
21268    #[test]
21269    fn load_production_profile_reads_inline_profile_production_section() {
21270        let dir = tempfile::tempdir().unwrap();
21271        let base_path = dir.path().join("autumn.toml");
21272        std::fs::write(
21273            &base_path,
21274            r"
21275            [profile.production.server]
21276            port = 4200
21277            ",
21278        )
21279        .unwrap();
21280
21281        let env = MockEnv::new()
21282            .with("AUTUMN_ENV", "production")
21283            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21284
21285        let config = AutumnConfig::load_with_env(&env).unwrap();
21286        assert_eq!(config.profile.as_deref(), Some("prod"));
21287        assert_eq!(config.server.port, 4200);
21288    }
21289
21290    #[test]
21291    fn load_production_profile_reads_legacy_autumn_production_toml() {
21292        let dir = tempfile::tempdir().unwrap();
21293        let production_path = dir.path().join("autumn-production.toml");
21294        std::fs::write(
21295            &production_path,
21296            r"
21297            [server]
21298            port = 4300
21299            ",
21300        )
21301        .unwrap();
21302
21303        let env = MockEnv::new()
21304            .with("AUTUMN_ENV", "production")
21305            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21306
21307        let config = AutumnConfig::load_with_env(&env).unwrap();
21308        assert_eq!(config.profile.as_deref(), Some("prod"));
21309        assert_eq!(config.server.port, 4300);
21310    }
21311
21312    #[test]
21313    fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
21314        let dir = tempfile::tempdir().unwrap();
21315        let prod_path = dir.path().join("autumn-prod.toml");
21316        let production_path = dir.path().join("autumn-production.toml");
21317
21318        std::fs::write(
21319            &prod_path,
21320            r"
21321            [server]
21322            port = 4400
21323            ",
21324        )
21325        .unwrap();
21326        // Malformed TOML should be ignored because `autumn-prod.toml` is chosen first.
21327        std::fs::write(&production_path, "[server\nport = 4500").unwrap();
21328
21329        let env = MockEnv::new()
21330            .with("AUTUMN_ENV", "prod")
21331            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21332
21333        let config = AutumnConfig::load_with_env(&env).unwrap();
21334        assert_eq!(config.profile.as_deref(), Some("prod"));
21335        assert_eq!(config.server.port, 4400);
21336    }
21337
21338    #[test]
21339    fn load_production_prefers_autumn_production_toml_before_prod_alias() {
21340        let dir = tempfile::tempdir().unwrap();
21341        let prod_path = dir.path().join("autumn-prod.toml");
21342        let production_path = dir.path().join("autumn-production.toml");
21343
21344        std::fs::write(
21345            &production_path,
21346            r"
21347            [server]
21348            port = 4500
21349            ",
21350        )
21351        .unwrap();
21352        // Malformed TOML should be ignored because `autumn-production.toml` is chosen first.
21353        std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
21354
21355        let env = MockEnv::new()
21356            .with("AUTUMN_ENV", "production")
21357            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21358
21359        let config = AutumnConfig::load_with_env(&env).unwrap();
21360        assert_eq!(config.profile.as_deref(), Some("prod"));
21361        assert_eq!(config.server.port, 4500);
21362    }
21363
21364    #[test]
21365    fn load_from_io_error_is_not_swallowed() {
21366        // load_from should return Err on non-NotFound IO errors.
21367        // On all platforms, trying to read a directory as a file triggers an error.
21368        let dir = tempfile::tempdir().unwrap();
21369        let result = AutumnConfig::load_from(dir.path());
21370        assert!(result.is_err());
21371    }
21372
21373    #[test]
21374    fn load_raw_toml_missing_file_returns_none() {
21375        let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
21376        assert!(result.is_none());
21377    }
21378
21379    #[test]
21380    fn load_raw_toml_directory_returns_io_error() {
21381        // Reading a directory is an IO error, NOT NotFound.
21382        // This kills the "replace match guard NotFound with true" mutant:
21383        // if the guard were always true, this would return Ok(None) instead of Err.
21384        let dir = tempfile::tempdir().unwrap();
21385        let result = load_raw_toml(dir.path());
21386        assert!(result.is_err());
21387    }
21388
21389    #[test]
21390    fn load_raw_toml_valid_file_returns_some() {
21391        let dir = tempfile::tempdir().unwrap();
21392        let path = dir.path().join("test.toml");
21393        std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
21394        let result = load_raw_toml(&path).unwrap();
21395        assert!(result.is_some());
21396        assert_eq!(
21397            result.unwrap()["server"]["port"],
21398            toml::Value::Integer(3000)
21399        );
21400    }
21401
21402    #[test]
21403    fn env_override_log_format_auto() {
21404        // Kills the "delete match arm Auto" mutant
21405        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
21406        let mut config = AutumnConfig::default();
21407        // Start with non-Auto to prove the override works
21408        config.log.format = LogFormat::Json;
21409        config.apply_env_overrides_with_env(&env);
21410        assert_eq!(config.log.format, LogFormat::Auto);
21411    }
21412
21413    #[test]
21414    fn env_override_health_detailed_false() {
21415        // Kills the 'delete match arm "false" | "0"' mutant
21416        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
21417        let mut config = AutumnConfig::default();
21418        config.health.detailed = true; // start true, override to false
21419        config.apply_env_overrides_with_env(&env);
21420        assert!(!config.health.detailed);
21421    }
21422
21423    #[test]
21424    fn env_override_health_detailed_zero() {
21425        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
21426        let mut config = AutumnConfig::default();
21427        config.health.detailed = true;
21428        config.apply_env_overrides_with_env(&env);
21429        assert!(!config.health.detailed);
21430    }
21431
21432    #[test]
21433    fn health_enabled_defaults_true() {
21434        // Issue #1971: the probe off-switch is opt-in — enabled by default so
21435        // behavior is byte-identical to before the field existed.
21436        assert!(HealthConfig::default().enabled);
21437    }
21438
21439    #[test]
21440    fn env_override_health_enabled_false() {
21441        // Issue #1971: AUTUMN_HEALTH__ENABLED=false flips the built-in probe
21442        // off-switch via env, mirroring the AUTUMN_HEALTH__DETAILED wiring.
21443        let env = MockEnv::new().with("AUTUMN_HEALTH__ENABLED", "false");
21444        let mut config = AutumnConfig::default();
21445        assert!(config.health.enabled); // starts true (default)
21446        config.apply_env_overrides_with_env(&env);
21447        assert!(!config.health.enabled);
21448    }
21449
21450    #[test]
21451    fn cors_defaults() {
21452        let cors = CorsConfig::default();
21453        assert!(cors.allowed_origins.is_empty());
21454        assert_eq!(cors.allowed_methods.len(), 6);
21455        assert!(cors.allowed_methods.contains(&"GET".to_owned()));
21456        assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
21457        assert!(!cors.allow_credentials);
21458        assert_eq!(cors.max_age_secs, 86400);
21459    }
21460
21461    #[test]
21462    fn cors_in_full_config_defaults() {
21463        let config = AutumnConfig::default();
21464        assert!(config.cors.allowed_origins.is_empty());
21465    }
21466
21467    #[test]
21468    fn actuator_defaults() {
21469        let config = ActuatorConfig::default();
21470        assert_eq!(config.prefix, "/actuator");
21471        assert!(!config.sensitive);
21472        // Prometheus metrics export is on by default and independent of
21473        // `sensitive`, so platform scraping works without exposing env/loggers.
21474        assert!(config.prometheus);
21475    }
21476
21477    #[test]
21478    fn actuator_prometheus_can_be_disabled_via_toml() {
21479        let toml = r"
21480            sensitive = false
21481            prometheus = false
21482        ";
21483        let config: ActuatorConfig = toml::from_str(toml).unwrap();
21484        assert!(!config.sensitive);
21485        assert!(!config.prometheus);
21486    }
21487
21488    #[test]
21489    fn actuator_prefix_in_full_config() {
21490        let config = AutumnConfig::default();
21491        assert_eq!(config.actuator.prefix, "/actuator");
21492    }
21493
21494    #[test]
21495    fn deep_merge_handles_deep_nesting() {
21496        let mut base = toml::Value::Table(toml::map::Map::new());
21497        let mut overlay = toml::Value::Table(toml::map::Map::new());
21498
21499        // Create a 10,000 deep nested table
21500        let mut current_base = &mut base;
21501        let mut current_overlay = &mut overlay;
21502
21503        for _ in 0..10_000 {
21504            if let toml::Value::Table(t) = current_base {
21505                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21506                current_base = t.get_mut("x").unwrap();
21507            }
21508            if let toml::Value::Table(t) = current_overlay {
21509                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21510                current_overlay = t.get_mut("x").unwrap();
21511            }
21512        }
21513
21514        // Add a leaf value to test actual merging
21515        if let toml::Value::Table(t) = current_overlay {
21516            t.insert("y".to_owned(), toml::Value::Integer(42));
21517        }
21518
21519        // Trigger merge, expecting no panic/stack overflow
21520        // We run it on a thread with a large stack to avoid the stack overflow caused by Drop when base is dropped at the end of the function (since we created a 10,000 depth structure).
21521        std::thread::Builder::new()
21522            .stack_size(32 * 1024 * 1024)
21523            .spawn(move || {
21524                deep_merge(&mut base, overlay);
21525                // Let the OS clean up the memory instead of dropping deeply nested structure
21526                std::mem::forget(base);
21527            })
21528            .unwrap()
21529            .join()
21530            .unwrap();
21531    }
21532
21533    #[test]
21534    fn deep_merge_stops_at_max_depth() {
21535        let mut base = toml::Value::Table(toml::map::Map::new());
21536        let mut overlay = toml::Value::Table(toml::map::Map::new());
21537
21538        // Create structures nested exactly to MAX_MERGE_DEPTH + 1
21539        let mut current_base = &mut base;
21540        let mut current_overlay = &mut overlay;
21541
21542        for _ in 0..=MAX_MERGE_DEPTH {
21543            if let toml::Value::Table(t) = current_base {
21544                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21545                current_base = t.get_mut("x").unwrap();
21546            }
21547            if let toml::Value::Table(t) = current_overlay {
21548                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21549                current_overlay = t.get_mut("x").unwrap();
21550            }
21551        }
21552
21553        // Add a value deep in the overlay
21554        if let toml::Value::Table(t) = current_overlay {
21555            t.insert("deep_value".to_owned(), toml::Value::Integer(123));
21556        }
21557
21558        deep_merge(&mut base, overlay);
21559
21560        // Verify the value was NOT merged due to max depth limit
21561        let mut current_base_check = &base;
21562        for _ in 0..=MAX_MERGE_DEPTH {
21563            if let toml::Value::Table(t) = current_base_check {
21564                current_base_check = t.get("x").unwrap();
21565            }
21566        }
21567
21568        if let toml::Value::Table(t) = current_base_check {
21569            assert!(
21570                !t.contains_key("deep_value"),
21571                "Value beyond MAX_MERGE_DEPTH should not be merged"
21572            );
21573        } else {
21574            panic!("Expected a table");
21575        }
21576    }
21577
21578    // ── AUTUMN_SECURITY__FORBIDDEN_RESPONSE / __ALLOW_UNAUTHORIZED_REPOSITORY_API ──
21579
21580    #[test]
21581    fn env_override_forbidden_response_403() {
21582        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
21583        let mut config = AutumnConfig::default();
21584        config.apply_env_overrides_with_env(&env);
21585        assert_eq!(
21586            config.security.forbidden_response,
21587            crate::authorization::ForbiddenResponse::Forbidden403
21588        );
21589    }
21590
21591    #[test]
21592    fn env_override_forbidden_response_404() {
21593        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
21594        let mut config = AutumnConfig::default();
21595        // Pre-set to 403 to confirm env actually flips it back to 404.
21596        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
21597        config.apply_env_overrides_with_env(&env);
21598        assert_eq!(
21599            config.security.forbidden_response,
21600            crate::authorization::ForbiddenResponse::NotFound404
21601        );
21602    }
21603
21604    #[test]
21605    fn env_override_forbidden_response_invalid_keeps_existing() {
21606        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
21607        let mut config = AutumnConfig::default();
21608        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
21609        config.apply_env_overrides_with_env(&env);
21610        // Invalid value warns and leaves the existing setting alone.
21611        assert_eq!(
21612            config.security.forbidden_response,
21613            crate::authorization::ForbiddenResponse::Forbidden403
21614        );
21615    }
21616
21617    #[test]
21618    fn env_override_allow_unauthorized_repository_api() {
21619        let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
21620        let mut config = AutumnConfig::default();
21621        assert!(!config.security.allow_unauthorized_repository_api);
21622        config.apply_env_overrides_with_env(&env);
21623        assert!(config.security.allow_unauthorized_repository_api);
21624    }
21625
21626    #[test]
21627    fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
21628        let env = MockEnv::new().with(
21629            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
21630            "false",
21631        );
21632        let mut config = AutumnConfig::default();
21633        config.security.allow_unauthorized_repository_api = true;
21634        config.apply_env_overrides_with_env(&env);
21635        assert!(!config.security.allow_unauthorized_repository_api);
21636    }
21637
21638    #[test]
21639    fn env_override_csrf_token_scan_bytes() {
21640        let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "8388608");
21641        let mut config = AutumnConfig::default();
21642        // Default is 2 MiB; the env override must raise it.
21643        assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
21644        config.apply_env_overrides_with_env(&env);
21645        assert_eq!(config.security.csrf.token_scan_bytes, 8_388_608);
21646    }
21647
21648    #[test]
21649    fn env_override_csrf_token_scan_bytes_invalid_is_ignored() {
21650        let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "not-a-number");
21651        let mut config = AutumnConfig::default();
21652        config.apply_env_overrides_with_env(&env);
21653        // Invalid values are ignored, leaving the default intact.
21654        assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
21655    }
21656
21657    // ── [openapi] config section tests (RED phase) ─────────────────────────
21658
21659    #[test]
21660    fn openapi_runtime_config_defaults_enabled() {
21661        // The [openapi] section must default to enabled=true and path="/openapi.json".
21662        let config = AutumnConfig::default();
21663        assert!(
21664            config.openapi_runtime.enabled,
21665            "[openapi] must default to enabled = true"
21666        );
21667        assert_eq!(
21668            config.openapi_runtime.path, "/openapi.json",
21669            "[openapi] must default to path = \"/openapi.json\""
21670        );
21671    }
21672
21673    #[test]
21674    fn openapi_runtime_config_can_be_disabled_via_toml() {
21675        let toml_str = "
21676[openapi]
21677enabled = false
21678";
21679        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
21680        assert!(
21681            !config.openapi_runtime.enabled,
21682            "[openapi] enabled = false must deserialize correctly"
21683        );
21684    }
21685
21686    #[test]
21687    fn openapi_runtime_config_path_can_be_customized() {
21688        let toml_str = r#"
21689[openapi]
21690path = "/api-spec.json"
21691"#;
21692        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
21693        assert_eq!(
21694            config.openapi_runtime.path, "/api-spec.json",
21695            "[openapi] path must deserialize correctly"
21696        );
21697    }
21698
21699    #[test]
21700    fn cache_env_overrides_fields() {
21701        let env = MockEnv::new()
21702            .with("AUTUMN_CACHE__BACKEND", "redis")
21703            .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
21704            .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
21705        let mut config = AutumnConfig::default();
21706
21707        config.apply_env_overrides_with_env(&env);
21708
21709        assert!(config.cache.is_redis(), "backend should be redis");
21710        assert_eq!(
21711            config.cache.redis.url.as_deref(),
21712            Some("redis://cache:6379/1")
21713        );
21714        assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
21715    }
21716
21717    #[test]
21718    fn cache_backend_from_env_value_invalid_is_none() {
21719        assert!(CacheBackend::from_env_value("postgres").is_none());
21720        assert!(CacheBackend::from_env_value("").is_none());
21721    }
21722
21723    #[test]
21724    fn scheduler_validate_rejects_zero_lease_ttl() {
21725        let cfg = SchedulerConfig {
21726            lease_ttl_secs: 0,
21727            ..SchedulerConfig::default()
21728        };
21729        assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
21730    }
21731
21732    #[test]
21733    fn scheduler_validate_rejects_empty_key_prefix() {
21734        let cfg = SchedulerConfig {
21735            key_prefix: "   ".to_owned(),
21736            ..SchedulerConfig::default()
21737        };
21738        assert!(cfg.validate().is_err(), "blank key_prefix must fail");
21739    }
21740
21741    #[test]
21742    fn scheduler_validate_ok_with_defaults() {
21743        assert!(SchedulerConfig::default().validate().is_ok());
21744    }
21745
21746    #[test]
21747    fn scheduler_resolved_replica_id_uses_explicit_value() {
21748        let cfg = SchedulerConfig {
21749            replica_id: Some("my-pod".to_owned()),
21750            ..SchedulerConfig::default()
21751        };
21752        assert_eq!(cfg.resolved_replica_id(), "my-pod");
21753    }
21754
21755    #[test]
21756    fn scheduler_resolved_replica_id_falls_back_to_pid() {
21757        let cfg = SchedulerConfig {
21758            replica_id: None,
21759            ..SchedulerConfig::default()
21760        };
21761        // In CI, FLY_MACHINE_ID and HOSTNAME may or may not be set,
21762        // so just verify we get a non-empty string back.
21763        assert!(!cfg.resolved_replica_id().is_empty());
21764    }
21765
21766    #[cfg(feature = "mail")]
21767    #[test]
21768    fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
21769        let env = MockEnv::new()
21770            .with(
21771                "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
21772                "true",
21773            )
21774            .with("AUTUMN_MAIL__TRANSPORT", "smtp")
21775            .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
21776
21777        let mut config = AutumnConfig::default();
21778        config.apply_mail_env_overrides_with_env(&env);
21779
21780        assert!(
21781            config.mail.allow_in_process_deliver_later_in_production,
21782            "env var should set allow_in_process_deliver_later_in_production"
21783        );
21784    }
21785
21786    #[cfg(feature = "mail")]
21787    #[test]
21788    fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
21789        let env = MockEnv::new();
21790        let mut config = AutumnConfig::default();
21791        config.apply_mail_env_overrides_with_env(&env);
21792
21793        assert!(
21794            !config.mail.allow_in_process_deliver_later_in_production,
21795            "flag should default to false when env var is not set"
21796        );
21797    }
21798
21799    #[cfg(feature = "mail")]
21800    #[test]
21801    fn mail_inline_css_is_overridable_via_env() {
21802        let env = MockEnv::new().with("AUTUMN_MAIL__INLINE_CSS", "true");
21803
21804        let mut config = AutumnConfig::default();
21805        config.apply_mail_env_overrides_with_env(&env);
21806
21807        assert!(
21808            config.mail.inline_css,
21809            "AUTUMN_MAIL__INLINE_CSS=true should enable inline_css"
21810        );
21811    }
21812
21813    #[cfg(feature = "mail")]
21814    #[test]
21815    fn mail_inline_css_defaults_false() {
21816        let env = MockEnv::new();
21817        let mut config = AutumnConfig::default();
21818        config.apply_mail_env_overrides_with_env(&env);
21819
21820        assert!(
21821            !config.mail.inline_css,
21822            "inline_css should default to false when env var is not set"
21823        );
21824    }
21825
21826    // ── credentials integration ───────────────────────────────────────────
21827
21828    #[test]
21829    fn config_credentials_empty_when_no_directory() {
21830        let env = MockEnv::new();
21831        let config = AutumnConfig::load_with_env(&env).unwrap();
21832        assert!(
21833            config.credentials().is_empty(),
21834            "existing apps without config/credentials/ must boot with an empty credentials store"
21835        );
21836    }
21837
21838    #[test]
21839    fn config_has_credentials_accessor() {
21840        let config = AutumnConfig::default();
21841        let _store = config.credentials();
21842    }
21843
21844    #[test]
21845    fn config_credentials_loaded_when_file_present() {
21846        use crate::credentials::{MasterKey, encrypt};
21847        use tempfile::TempDir;
21848
21849        let tmp = TempDir::new().unwrap();
21850        let key = MasterKey::generate();
21851        let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
21852        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21853        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21854
21855        let env = MockEnv::new()
21856            .with("AUTUMN_MASTER_KEY", &key.to_hex())
21857            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21858        let config = AutumnConfig::load_with_env(&env).unwrap();
21859        let val: Option<String> = config.credentials().get("stripe_key");
21860        assert_eq!(val.as_deref(), Some("sk_test_xyz"));
21861    }
21862
21863    #[cfg(feature = "oauth2")]
21864    #[test]
21865    fn config_resolves_oauth_credentials_by_convention() {
21866        use crate::credentials::{MasterKey, encrypt};
21867        use tempfile::TempDir;
21868
21869        let tmp = TempDir::new().unwrap();
21870        let key = MasterKey::generate();
21871        let ct = encrypt(
21872            &key,
21873            b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
21874        );
21875        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21876        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21877
21878        // Write a base configuration with an empty/blank github provider defined
21879        std::fs::create_dir_all(tmp.path().join("config")).unwrap();
21880        let config_toml = r#"
21881[auth.oauth2.github]
21882client_id = ""
21883client_secret = ""
21884authorize_url = "https://github.com/login/oauth/authorize"
21885token_url = "https://github.com/login/oauth/access_token"
21886redirect_uri = "http://localhost:3000/auth/github/callback"
21887"#;
21888        std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
21889
21890        let env = MockEnv::new()
21891            .with("AUTUMN_MASTER_KEY", &key.to_hex())
21892            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21893        let config = AutumnConfig::load_with_env(&env).unwrap();
21894        let github = config.auth.oauth2.providers.get("github").unwrap();
21895        assert_eq!(github.client_id, "git-id-123");
21896        assert_eq!(github.client_secret, "git-secret-456");
21897    }
21898
21899    #[test]
21900    fn config_fails_with_credentials_error_when_key_is_invalid() {
21901        use crate::credentials::encrypt;
21902        use tempfile::TempDir;
21903
21904        let tmp = TempDir::new().unwrap();
21905        // Write an encrypted file but supply a wrong-length key so validation fails
21906        let bogus_key = "zz".repeat(32); // 64 chars but not valid hex
21907        let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
21908        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21909        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21910
21911        let env = MockEnv::new()
21912            .with("AUTUMN_MASTER_KEY", &bogus_key)
21913            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21914        let err = AutumnConfig::load_with_env(&env).unwrap_err();
21915        assert!(
21916            matches!(err, ConfigError::Credentials(_)),
21917            "bad master key should produce ConfigError::Credentials, got {err:?}"
21918        );
21919    }
21920
21921    #[test]
21922    fn test_parse_duration_str() {
21923        assert_eq!(
21924            parse_duration_str("500ms").unwrap(),
21925            std::time::Duration::from_millis(500)
21926        );
21927        assert_eq!(
21928            parse_duration_str("5s").unwrap(),
21929            std::time::Duration::from_secs(5)
21930        );
21931        assert_eq!(
21932            parse_duration_str("2m").unwrap(),
21933            std::time::Duration::from_secs(120)
21934        );
21935        assert_eq!(
21936            parse_duration_str("1h").unwrap(),
21937            std::time::Duration::from_secs(3600)
21938        );
21939        assert_eq!(
21940            parse_duration_str("1000").unwrap(),
21941            std::time::Duration::from_secs(1)
21942        );
21943        assert!(parse_duration_str("abc").is_err());
21944        assert!(parse_duration_str("").is_err());
21945    }
21946
21947    #[test]
21948    fn test_database_config_duration_deserialization() {
21949        #[derive(Debug, Deserialize)]
21950        struct TestConfig {
21951            #[serde(deserialize_with = "deserialize_option_duration", default)]
21952            timeout: Option<std::time::Duration>,
21953            #[serde(deserialize_with = "deserialize_duration")]
21954            threshold: std::time::Duration,
21955        }
21956
21957        let toml_str = r#"
21958            timeout = "2s"
21959            threshold = "100ms"
21960        "#;
21961        let parsed: TestConfig = toml::from_str(toml_str).unwrap();
21962        assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
21963        assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
21964
21965        let toml_str_null = r#"
21966            threshold = "500"
21967        "#;
21968        let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
21969        assert_eq!(parsed_null.timeout, None);
21970        assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
21971    }
21972
21973    // ── RequestTimeoutsConfig ──────────────────────────────────────────────
21974
21975    #[test]
21976    fn request_timeouts_config_defaults_to_none() {
21977        let config = RequestTimeoutsConfig::default();
21978        assert!(config.request_timeout_ms.is_none());
21979    }
21980
21981    #[test]
21982    fn server_config_timeouts_defaults_to_disabled() {
21983        let config = ServerConfig::default();
21984        assert!(config.timeouts.request_timeout_ms.is_none());
21985    }
21986
21987    #[test]
21988    fn request_timeouts_config_can_be_set_via_toml() {
21989        let toml_str = "request_timeout_ms = 5000";
21990        let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
21991        assert_eq!(config.request_timeout_ms, Some(5000));
21992    }
21993
21994    #[test]
21995    fn server_config_timeouts_deserialize_nested() {
21996        let toml_str = r#"
21997            port = 3000
21998            host = "127.0.0.1"
21999            shutdown_timeout_secs = 30
22000            prestop_grace_secs = 5
22001
22002            [timeouts]
22003            request_timeout_ms = 15000
22004        "#;
22005        let config: ServerConfig = toml::from_str(toml_str).unwrap();
22006        assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
22007    }
22008
22009    #[test]
22010    fn prod_drain_window_is_request_timeout_plus_margin() {
22011        let defaults = profile_defaults_as_toml("prod");
22012        let config: AutumnConfig = toml::from_str(&toml::to_string(&defaults).unwrap()).unwrap();
22013        let timeout_secs = config.server.timeouts.request_timeout_ms.unwrap() / 1000;
22014        assert_eq!(
22015            config.server.shutdown_timeout_secs,
22016            timeout_secs + DRAIN_MARGIN_SECS
22017        );
22018        assert!(config.server.drain_window_warning().is_none());
22019    }
22020
22021    #[test]
22022    fn drain_window_shorter_than_request_timeout_is_flagged() {
22023        let mut server = ServerConfig::default();
22024        server.timeouts.request_timeout_ms = Some(30_000);
22025        server.shutdown_timeout_secs = 30;
22026        let warning = server
22027            .drain_window_warning()
22028            .expect("30 s drain, 30 s timeout");
22029        assert!(warning.contains("35"), "names the safe value: {warning}");
22030
22031        server.shutdown_timeout_secs = 35;
22032        assert!(server.drain_window_warning().is_none());
22033
22034        server.timeouts.request_timeout_ms = Some(30_001);
22035        assert!(server.drain_window_warning().is_some(), "rounds up to 36 s");
22036        server.shutdown_timeout_secs = 36;
22037        assert!(server.drain_window_warning().is_none());
22038
22039        server.timeouts.request_timeout_ms = None;
22040        server.shutdown_timeout_secs = 1;
22041        assert!(
22042            server.drain_window_warning().is_none(),
22043            "no timeout, no risk"
22044        );
22045    }
22046
22047    #[test]
22048    fn accept_deadline_header_is_off_by_default_and_configurable() {
22049        assert!(!RequestTimeoutsConfig::default().accept_deadline_header);
22050        let config: ServerConfig =
22051            toml::from_str("[timeouts]\naccept_deadline_header = true\n").unwrap();
22052        assert!(config.timeouts.accept_deadline_header);
22053
22054        let env = MockEnv::new().with("AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER", "true");
22055        let mut config = AutumnConfig::default();
22056        config.apply_env_overrides_with_env(&env);
22057        assert!(config.server.timeouts.accept_deadline_header);
22058    }
22059
22060    #[cfg(feature = "http-client")]
22061    #[test]
22062    fn http_client_deadline_header_and_retry_budget_defaults() {
22063        let config = HttpClientConfig::default();
22064        assert!(config.send_deadline_header);
22065        let budget = &config.retry_budget;
22066        assert!(budget.enabled);
22067        assert_eq!(budget.capacity, 500);
22068        assert_eq!(budget.transient_cost, 14);
22069        assert_eq!(budget.throttling_cost, 5);
22070        assert!((budget.retry_ratio - 0.1).abs() < f64::EPSILON);
22071    }
22072
22073    #[cfg(feature = "http-client")]
22074    #[test]
22075    fn http_client_retry_budget_rejects_bad_values() {
22076        let mut config = AutumnConfig::default();
22077        assert!(config.validate().is_ok());
22078        config.http.client.retry_budget.retry_ratio = 1.5;
22079        assert!(config.validate().is_err());
22080        config.http.client.retry_budget.retry_ratio = f64::NAN;
22081        assert!(config.validate().is_err());
22082        config.http.client.retry_budget.retry_ratio = 0.1;
22083        config.http.client.retry_budget.transient_cost = 0;
22084        assert!(config.validate().is_err());
22085    }
22086
22087    #[cfg(feature = "http-client")]
22088    #[test]
22089    fn a_disabled_retry_budget_is_not_validated() {
22090        let mut config = AutumnConfig::default();
22091        config.http.client.retry_budget.enabled = false;
22092        config.http.client.retry_budget.transient_cost = 0;
22093        config.http.client.retry_budget.throttling_cost = 0;
22094        config.http.client.retry_budget.retry_ratio = 2.0;
22095        assert!(config.validate().is_ok(), "{:?}", config.validate());
22096    }
22097
22098    #[cfg(feature = "http-client")]
22099    #[test]
22100    fn http_client_retry_budget_parses_from_toml() {
22101        let config: HttpConfig = toml::from_str(
22102            "[client]\nsend_deadline_header = false\n\
22103             [client.retry_budget]\nenabled = false\ncapacity = 50\nretry_ratio = 0.2\n",
22104        )
22105        .unwrap();
22106        assert!(!config.client.send_deadline_header);
22107        assert!(!config.client.retry_budget.enabled);
22108        assert_eq!(config.client.retry_budget.capacity, 50);
22109        assert_eq!(config.client.retry_budget.transient_cost, 14);
22110        assert!((config.client.retry_budget.retry_ratio - 0.2).abs() < f64::EPSILON);
22111    }
22112
22113    #[test]
22114    fn autumn_config_server_timeouts_roundtrip() {
22115        let mut config = AutumnConfig::default();
22116        config.server.timeouts.request_timeout_ms = Some(20_000);
22117        assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
22118    }
22119
22120    #[test]
22121    fn server_timeouts_env_var_override() {
22122        struct FakeEnv(std::collections::HashMap<String, String>);
22123        impl Env for FakeEnv {
22124            fn var(&self, key: &str) -> Result<String, std::env::VarError> {
22125                self.0
22126                    .get(key)
22127                    .cloned()
22128                    .ok_or(std::env::VarError::NotPresent)
22129            }
22130        }
22131
22132        let mut config = AutumnConfig::default();
22133        let env = FakeEnv(
22134            [(
22135                "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
22136                "8000".to_owned(),
22137            )]
22138            .into(),
22139        );
22140        config.apply_server_env_overrides_with_env(&env);
22141        assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
22142    }
22143
22144    #[test]
22145    fn prod_profile_sets_request_timeout_30s() {
22146        let defaults = profile_defaults_as_toml("prod");
22147        let toml_str = toml::to_string(&defaults).unwrap();
22148        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
22149        assert_eq!(
22150            config.server.timeouts.request_timeout_ms,
22151            Some(30_000),
22152            "prod profile must enable the 30-second request timeout by default"
22153        );
22154    }
22155
22156    #[test]
22157    fn dev_profile_leaves_request_timeout_disabled() {
22158        let defaults = profile_defaults_as_toml("dev");
22159        let toml_str = toml::to_string(&defaults).unwrap();
22160        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
22161        assert!(
22162            config.server.timeouts.request_timeout_ms.is_none(),
22163            "dev profile must not enable a request timeout by default"
22164        );
22165    }
22166
22167    // ── [server.http] (issue #3065) ────────────────────────────────
22168
22169    #[test]
22170    fn server_http_defaults_are_unset() {
22171        assert_eq!(
22172            AutumnConfig::default().server.http,
22173            HttpServerConfig::default()
22174        );
22175        let dev: AutumnConfig =
22176            toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
22177        assert_eq!(dev.server.http, HttpServerConfig::default());
22178    }
22179
22180    #[test]
22181    fn prod_profile_sets_safe_server_http_limits() {
22182        let prod: AutumnConfig =
22183            toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
22184        let http = prod.server.http;
22185        assert_eq!(http.header_read_timeout_ms, Some(10_000));
22186        assert_eq!(http.keep_alive_timeout_ms, Some(75_000));
22187        assert_eq!(http.max_header_bytes, Some(65_536));
22188        assert_eq!(http.http2_max_concurrent_streams, Some(100));
22189        assert_eq!(http.max_connections, Some(10_000));
22190    }
22191
22192    #[test]
22193    fn server_http_parses_from_toml() {
22194        let config: AutumnConfig = toml::from_str(
22195            r"
22196            [server.http]
22197            header_read_timeout_ms = 500
22198            keep_alive_timeout_ms = 900
22199            max_header_bytes = 16384
22200            http2_max_concurrent_streams = 8
22201            max_connections = 3
22202        ",
22203        )
22204        .unwrap();
22205        assert_eq!(
22206            config.server.http,
22207            HttpServerConfig {
22208                header_read_timeout_ms: Some(500),
22209                keep_alive_timeout_ms: Some(900),
22210                max_header_bytes: Some(16_384),
22211                http2_max_concurrent_streams: Some(8),
22212                max_connections: Some(3),
22213            }
22214        );
22215    }
22216
22217    // ── #3057: prod protections ────────────────────────────────────────────
22218
22219    /// Load `autumn.toml` (body `toml`) under `profile` with extra env vars.
22220    fn load_3057(profile: &str, toml: &str, vars: &[(&str, &str)]) -> AutumnConfig {
22221        try_load_3057(profile, toml, vars).expect("config loads")
22222    }
22223
22224    fn try_load_3057(
22225        profile: &str,
22226        toml: &str,
22227        vars: &[(&str, &str)],
22228    ) -> Result<AutumnConfig, ConfigError> {
22229        let temp = tempfile::tempdir().unwrap();
22230        std::fs::write(temp.path().join("autumn.toml"), toml).unwrap();
22231        let mut env: HashMap<String, String> = vars
22232            .iter()
22233            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
22234            .collect();
22235        env.insert("AUTUMN_ENV".to_owned(), profile.to_owned());
22236        env.insert(
22237            "AUTUMN_MANIFEST_DIR".to_owned(),
22238            temp.path().to_str().unwrap().to_owned(),
22239        );
22240        AutumnConfig::load_with_env(&FakeEnv(env))
22241    }
22242
22243    #[test]
22244    fn prod_profile_enables_strict_config() {
22245        assert!(load_3057("prod", "", &[]).server.strict_config);
22246        assert!(!load_3057("dev", "", &[]).server.strict_config);
22247    }
22248
22249    #[test]
22250    fn prod_profile_rejects_misspelled_timeout_key() {
22251        let toml = "[database]\nstatment_timeout = \"5s\"\n";
22252        let err = try_load_3057("prod", toml, &[]).expect_err("typo must fail in prod");
22253        assert!(format!("{err:?}").contains("statment_timeout"), "{err:?}");
22254        assert!(try_load_3057("dev", toml, &[]).is_ok());
22255    }
22256
22257    #[test]
22258    fn prod_strict_config_opt_out() {
22259        let toml = "[server]\nstrict_config = false\n[database]\nstatment_timeout = \"5s\"\n";
22260        assert!(!load_3057("prod", toml, &[]).server.strict_config);
22261        let toml = "[database]\nstatment_timeout = \"5s\"\n";
22262        let env = [("AUTUMN_SERVER__STRICT_CONFIG", "false")];
22263        assert!(!load_3057("prod", toml, &env).server.strict_config);
22264    }
22265
22266    #[cfg(not(feature = "sqlite"))]
22267    #[test]
22268    fn prod_profile_sets_database_timeouts() {
22269        let config = load_3057("prod", "", &[]);
22270        assert_eq!(
22271            config.database.statement_timeout,
22272            Some(std::time::Duration::from_secs(30))
22273        );
22274        assert_eq!(
22275            config.database.idle_in_transaction_timeout,
22276            Some(std::time::Duration::from_secs(60))
22277        );
22278    }
22279
22280    /// `SQLite` cannot enforce these timeouts and refuses to boot with one set.
22281    #[cfg(feature = "sqlite")]
22282    #[test]
22283    fn prod_profile_leaves_database_timeouts_off_on_sqlite() {
22284        let config = load_3057("prod", "", &[]);
22285        assert_eq!(config.database.statement_timeout, None);
22286        assert_eq!(config.database.idle_in_transaction_timeout, None);
22287    }
22288
22289    #[test]
22290    fn dev_profile_leaves_database_timeouts_off() {
22291        let config = load_3057("dev", "", &[]);
22292        assert_eq!(config.database.statement_timeout, None);
22293        assert_eq!(config.database.idle_in_transaction_timeout, None);
22294    }
22295
22296    #[test]
22297    fn prod_database_timeouts_opt_out_via_toml() {
22298        let toml = "[database]\nstatement_timeout = \"0s\"\nidle_in_transaction_timeout = \"0s\"\n";
22299        let config = load_3057("prod", toml, &[]);
22300        assert_eq!(
22301            config.database.statement_timeout,
22302            Some(std::time::Duration::ZERO)
22303        );
22304        assert_eq!(
22305            config.database.idle_in_transaction_timeout,
22306            Some(std::time::Duration::ZERO)
22307        );
22308    }
22309
22310    #[test]
22311    fn server_http_env_overrides() {
22312        let env = MockEnv::new()
22313            .with("AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS", "1500")
22314            .with("AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS", "2500")
22315            .with("AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES", "9000")
22316            .with("AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS", "7")
22317            .with("AUTUMN_SERVER__HTTP__MAX_CONNECTIONS", "42");
22318        let mut config = AutumnConfig::default();
22319        config.apply_server_env_overrides_with_env(&env);
22320        assert_eq!(config.server.http.header_read_timeout_ms, Some(1500));
22321        assert_eq!(config.server.http.keep_alive_timeout_ms, Some(2500));
22322        assert_eq!(config.server.http.max_header_bytes, Some(9000));
22323        assert_eq!(config.server.http.http2_max_concurrent_streams, Some(7));
22324        assert_eq!(config.server.http.max_connections, Some(42));
22325    }
22326
22327    #[test]
22328    fn server_http_rejects_unusable_values() {
22329        let mut config = AutumnConfig::default();
22330        config.server.http.max_header_bytes = Some(1024);
22331        let err = config.validate().unwrap_err().to_string();
22332        assert!(err.contains("server.http.max_header_bytes"), "{err}");
22333
22334        let mut config = AutumnConfig::default();
22335        config.server.http.http2_max_concurrent_streams = Some(0);
22336        let err = config.validate().unwrap_err().to_string();
22337        assert!(err.contains("http2_max_concurrent_streams"), "{err}");
22338
22339        let mut config = AutumnConfig::default();
22340        config.server.http.max_connections = Some(usize::MAX);
22341        let err = config.validate().unwrap_err().to_string();
22342        assert!(err.contains("server.http.max_connections"), "{err}");
22343
22344        let mut config = AutumnConfig::default();
22345        config.server.http.max_header_bytes = Some(HttpServerConfig::MIN_HEADER_BYTES);
22346        config.server.http.max_connections = Some(HttpServerConfig::MAX_CONNECTIONS);
22347        config.validate().unwrap();
22348    }
22349
22350    // ── [realtime] (issue #3065) ───────────────────────────────────
22351
22352    #[test]
22353    fn realtime_defaults_are_unset_outside_prod() {
22354        assert_eq!(AutumnConfig::default().realtime, RealtimeConfig::default());
22355        let dev: AutumnConfig =
22356            toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
22357        assert_eq!(dev.realtime, RealtimeConfig::default());
22358    }
22359
22360    #[test]
22361    fn prod_profile_sets_safe_realtime_limits() {
22362        let prod: AutumnConfig =
22363            toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
22364        assert_eq!(
22365            prod.realtime,
22366            RealtimeConfig {
22367                max_connections: None,
22368                max_message_bytes: Some(1_048_576),
22369                ping_interval_ms: Some(30_000),
22370                idle_timeout_ms: Some(120_000),
22371            }
22372        );
22373    }
22374
22375    #[test]
22376    fn database_timeouts_env_overrides() {
22377        let env = [
22378            ("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "0"),
22379            ("AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT", "15s"),
22380        ];
22381        let config = load_3057("prod", "", &env);
22382        assert_eq!(
22383            config.database.statement_timeout,
22384            Some(std::time::Duration::ZERO)
22385        );
22386        assert_eq!(
22387            config.database.idle_in_transaction_timeout,
22388            Some(std::time::Duration::from_secs(15))
22389        );
22390        // An empty value clears the setting.
22391        let env = [("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "")];
22392        assert_eq!(load_3057("prod", "", &env).database.statement_timeout, None);
22393    }
22394
22395    #[test]
22396    fn migration_lock_defaults_apply_to_every_profile() {
22397        for profile in ["dev", "prod", "staging"] {
22398            let db = load_3057(profile, "", &[]).database;
22399            assert_eq!(
22400                db.migration_lock_timeout,
22401                std::time::Duration::from_secs(5),
22402                "{profile}"
22403            );
22404            assert_eq!(db.migration_lock_retries, 5, "{profile}");
22405        }
22406    }
22407
22408    #[test]
22409    fn migration_lock_opt_out() {
22410        let toml = "[database]\nmigration_lock_timeout = \"0s\"\nmigration_lock_retries = 0\n";
22411        let db = load_3057("prod", toml, &[]).database;
22412        assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
22413        assert_eq!(db.migration_lock_retries, 0);
22414        let env = [
22415            ("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "2s"),
22416            ("AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES", "1"),
22417        ];
22418        let db = load_3057("prod", "", &env).database;
22419        assert_eq!(db.migration_lock_timeout, std::time::Duration::from_secs(2));
22420        assert_eq!(db.migration_lock_retries, 1);
22421        // An empty env value keeps the file value, as `autumn migrate` does.
22422        let toml = "[database]\nmigration_lock_timeout = \"0s\"\n";
22423        let env = [("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "")];
22424        let db = load_3057("prod", toml, &env).database;
22425        assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
22426    }
22427
22428    #[test]
22429    fn prod_profile_admission_default_is_pool_size_times_k() {
22430        let config = load_3057("prod", "[database]\npool_size = 20\n", &[]);
22431        assert_eq!(
22432            config.profile_admission_default(),
22433            Some(20 * PROD_REQUESTS_PER_POOL_CONNECTION)
22434        );
22435        // The primary role size wins over the shared default.
22436        let config = load_3057(
22437            "prod",
22438            "[database]\npool_size = 20\nprimary_pool_size = 9\n",
22439            &[],
22440        );
22441        assert_eq!(
22442            config.profile_admission_default(),
22443            Some(9 * PROD_REQUESTS_PER_POOL_CONNECTION)
22444        );
22445        // A small pool still gets the floor.
22446        let config = load_3057("prod", "[database]\nprimary_pool_size = 2\n", &[]);
22447        assert_eq!(
22448            config.profile_admission_default(),
22449            Some(PROD_MIN_ADMISSION_LIMIT)
22450        );
22451        assert_eq!(load_3057("dev", "", &[]).profile_admission_default(), None);
22452        assert_eq!(
22453            load_3057("staging", "", &[]).profile_admission_default(),
22454            None
22455        );
22456    }
22457
22458    #[test]
22459    fn realtime_env_overrides_and_validation() {
22460        let env = MockEnv::new()
22461            .with("AUTUMN_REALTIME__MAX_CONNECTIONS", "9")
22462            .with("AUTUMN_REALTIME__MAX_MESSAGE_BYTES", "4096")
22463            .with("AUTUMN_REALTIME__PING_INTERVAL_MS", "1000")
22464            .with("AUTUMN_REALTIME__IDLE_TIMEOUT_MS", "5000");
22465        let mut config = AutumnConfig::default();
22466        config.apply_realtime_env_overrides_with_env(&env);
22467        assert_eq!(
22468            config.realtime,
22469            RealtimeConfig {
22470                max_connections: Some(9),
22471                max_message_bytes: Some(4096),
22472                ping_interval_ms: Some(1000),
22473                idle_timeout_ms: Some(5000),
22474            }
22475        );
22476        config.realtime.max_message_bytes = Some(0);
22477        let err = config.validate().unwrap_err().to_string();
22478        assert!(err.contains("realtime.max_message_bytes"), "{err}");
22479    }
22480
22481    #[test]
22482    fn prod_profile_admission_default_resolves_to_a_ceiling() {
22483        let config = load_3057("prod", "", &[]);
22484        let limit = crate::capacity::resolve_configured_admission_limit_with_default(
22485            config.server.max_concurrent_requests,
22486            config.server.capacity_contract.as_deref(),
22487            config.profile_admission_default(),
22488        );
22489        assert_eq!(
22490            limit,
22491            crate::capacity::AdmissionLimit::ProfileDefault(
22492                default_pool_size() * PROD_REQUESTS_PER_POOL_CONNECTION
22493            )
22494        );
22495    }
22496
22497    #[test]
22498    fn prod_admission_default_opt_out() {
22499        for (toml, env) in [
22500            ("[server]\nmax_concurrent_requests = 0\n", vec![]),
22501            ("", vec![("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "0")]),
22502        ] {
22503            let config = load_3057("prod", toml, &env);
22504            let limit = crate::capacity::resolve_configured_admission_limit_with_default(
22505                config.server.max_concurrent_requests,
22506                config.server.capacity_contract.as_deref(),
22507                config.profile_admission_default(),
22508            );
22509            assert_eq!(limit, crate::capacity::AdmissionLimit::Unlimited);
22510        }
22511    }
22512
22513    #[test]
22514    fn test_resilience_config_defaults() {
22515        let config = AutumnConfig::default();
22516        assert!(
22517            config
22518                .resilience
22519                .circuit_breaker
22520                .defaults
22521                .failure_ratio_threshold
22522                .is_none()
22523        );
22524    }
22525
22526    #[test]
22527    fn test_resilience_config_parsing() {
22528        let toml_str = r#"
22529            [resilience.circuit_breaker.defaults]
22530            failure_ratio_threshold = 0.6
22531            sample_window_secs = 20
22532            minimum_sample_count = 15
22533            open_duration_secs = 30
22534            half_open_trial_count = 5
22535
22536            [resilience.circuit_breaker.hosts."api.github.com"]
22537            failure_ratio_threshold = 0.3
22538            open_duration_secs = 10
22539        "#;
22540        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
22541        let cb = &config.resilience.circuit_breaker;
22542        assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
22543        assert_eq!(cb.defaults.sample_window_secs, Some(20));
22544        assert_eq!(cb.defaults.minimum_sample_count, Some(15));
22545        assert_eq!(cb.defaults.open_duration_secs, Some(30));
22546        assert_eq!(cb.defaults.half_open_trial_count, Some(5));
22547
22548        let host_cb = cb.hosts.get("api.github.com").unwrap();
22549        assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
22550        assert_eq!(host_cb.open_duration_secs, Some(10));
22551        assert!(host_cb.sample_window_secs.is_none());
22552    }
22553
22554    #[test]
22555    fn test_resilience_config_env_overrides() {
22556        struct FakeEnv(std::collections::HashMap<String, String>);
22557        impl Env for FakeEnv {
22558            fn var(&self, key: &str) -> Result<String, std::env::VarError> {
22559                self.0
22560                    .get(key)
22561                    .cloned()
22562                    .ok_or(std::env::VarError::NotPresent)
22563            }
22564        }
22565
22566        let mut config = AutumnConfig::default();
22567        let env = FakeEnv(
22568            [(
22569                "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
22570                "0.7".to_owned(),
22571            )]
22572            .into(),
22573        );
22574        config.apply_resilience_env_overrides_with_env(&env);
22575        assert_eq!(
22576            config
22577                .resilience
22578                .circuit_breaker
22579                .defaults
22580                .failure_ratio_threshold,
22581            Some(0.7)
22582        );
22583    }
22584
22585    #[test]
22586    fn test_resilience_config_parses_slow_call_keys() {
22587        let toml_str = r#"
22588            [resilience.circuit_breaker.defaults]
22589            slow_call_duration_threshold_ms = 2500
22590            slow_call_rate_threshold = 0.4
22591            cancelled_call_outcome = "failure"
22592
22593            [resilience.circuit_breaker.hosts."api.github.com"]
22594            slow_call_duration_threshold_ms = 0
22595        "#;
22596        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
22597        let cb = &config.resilience.circuit_breaker;
22598        assert_eq!(cb.defaults.slow_call_duration_threshold_ms, Some(2500));
22599        assert_eq!(cb.defaults.slow_call_rate_threshold, Some(0.4));
22600        assert_eq!(
22601            cb.defaults.cancelled_call_outcome,
22602            Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
22603        );
22604        let host_cb = cb.hosts.get("api.github.com").unwrap();
22605        assert_eq!(host_cb.slow_call_duration_threshold_ms, Some(0));
22606        assert!(host_cb.slow_call_rate_threshold.is_none());
22607    }
22608
22609    #[test]
22610    fn test_resilience_config_slow_call_env_overrides() {
22611        let env = MockEnv::new()
22612            .with(
22613                "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
22614                "1500",
22615            )
22616            .with(
22617                "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
22618                "0.25",
22619            )
22620            .with(
22621                "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
22622                "failure",
22623            );
22624        let mut config = AutumnConfig::default();
22625        config.apply_resilience_env_overrides_with_env(&env);
22626        let defaults = &config.resilience.circuit_breaker.defaults;
22627        assert_eq!(defaults.slow_call_duration_threshold_ms, Some(1500));
22628        assert_eq!(defaults.slow_call_rate_threshold, Some(0.25));
22629        assert_eq!(
22630            defaults.cancelled_call_outcome,
22631            Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
22632        );
22633    }
22634
22635    #[test]
22636    fn test_resilience_config_bad_cancelled_call_outcome_env_is_ignored() {
22637        let env = MockEnv::new().with(
22638            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
22639            "drop",
22640        );
22641        let mut config = AutumnConfig::default();
22642        config.apply_resilience_env_overrides_with_env(&env);
22643        assert!(
22644            config
22645                .resilience
22646                .circuit_breaker
22647                .defaults
22648                .cancelled_call_outcome
22649                .is_none()
22650        );
22651    }
22652
22653    // ── Deprecation channel unit tests ────────────────────────────────────────
22654
22655    /// A tiny test-only registry so tests are independent of the real entries.
22656    const TEST_REGISTRY: &[DeprecatedKey] = &[DeprecatedKey {
22657        path: "a.b.c",
22658        replacement: Some("a.b.d"),
22659        since: "0.1.0",
22660        remove_in: "1.0.0",
22661    }];
22662
22663    fn merged_with_abc(value: toml::Value) -> toml::Table {
22664        let mut root = toml::Table::new();
22665        let mut b = toml::Table::new();
22666        b.insert("c".to_owned(), value);
22667        let mut a = toml::Table::new();
22668        a.insert("b".to_owned(), toml::Value::Table(b));
22669        root.insert("a".to_owned(), toml::Value::Table(a));
22670        root
22671    }
22672
22673    #[test]
22674    fn red_detect_from_toml_present_emits_finding() {
22675        let merged = merged_with_abc(toml::Value::Integer(1));
22676        let env = MockEnv::new(); // AUTUMN_A__B__C not set
22677        let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22678        assert_eq!(findings.len(), 1);
22679        let f = &findings[0];
22680        assert_eq!(f.path, "a.b.c");
22681        assert_eq!(f.replacement.as_deref(), Some("a.b.d"));
22682        assert_eq!(f.since, "0.1.0");
22683        assert_eq!(f.remove_in, "1.0.0");
22684        assert_eq!(f.source, DeprecationSource::Toml);
22685    }
22686
22687    #[test]
22688    fn red_detect_from_env_present_emits_finding() {
22689        let merged = toml::Table::new(); // no TOML key
22690        let env = MockEnv::new().with("AUTUMN_A__B__C", "val");
22691        let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22692        assert_eq!(findings.len(), 1);
22693        assert_eq!(findings[0].source, DeprecationSource::Env);
22694    }
22695
22696    #[test]
22697    fn red_detect_dedupe_toml_and_env_single_finding() {
22698        let merged = merged_with_abc(toml::Value::Boolean(true));
22699        let env = MockEnv::new().with("AUTUMN_A__B__C", "true");
22700        let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22701        assert_eq!(findings.len(), 1, "TOML+env should collapse to one finding");
22702        assert_eq!(findings[0].source, DeprecationSource::Both);
22703    }
22704
22705    #[test]
22706    fn red_detect_replacement_only_no_finding() {
22707        // Only the new replacement key is set; deprecated key is absent.
22708        let mut merged = toml::Table::new();
22709        let mut b = toml::Table::new();
22710        b.insert("d".to_owned(), toml::Value::Integer(1)); // new key, not deprecated
22711        let mut a = toml::Table::new();
22712        a.insert("b".to_owned(), toml::Value::Table(b));
22713        merged.insert("a".to_owned(), toml::Value::Table(a));
22714
22715        let env = MockEnv::new();
22716        let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22717        assert!(
22718            findings.is_empty(),
22719            "only replacement key set — no deprecation warning"
22720        );
22721    }
22722
22723    #[test]
22724    fn red_detect_absent_everywhere_no_finding() {
22725        let merged = toml::Table::new();
22726        let env = MockEnv::new();
22727        let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22728        assert!(findings.is_empty());
22729    }
22730
22731    #[test]
22732    fn red_env_var_name_mapping() {
22733        assert_eq!(
22734            deprecated_env_var_name("security.rate_limit.trusted_proxies"),
22735            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
22736        );
22737        assert_eq!(deprecated_env_var_name("a.b.c"), "AUTUMN_A__B__C");
22738    }
22739
22740    #[test]
22741    fn red_toml_path_non_table_mid_segment_not_present() {
22742        // If a mid-segment is not a Table, must return false without panicking.
22743        let mut root = toml::Table::new();
22744        root.insert("a".to_owned(), toml::Value::Integer(42)); // "a" is a leaf, not a table
22745        assert!(!toml_path_present(&root, "a.b.c"));
22746    }
22747
22748    #[test]
22749    fn red_schema_leaf_paths_includes_known_paths() {
22750        // The SchemaDeserializer recurses into any derived-Deserialize struct it
22751        // reaches, regardless of module — external-module types (SecurityConfig,
22752        // AuthConfig, etc.) now descend too. They were root-only before the #1890
22753        // adaptive walk because the walk aborted before them (at the
22754        // `statement_timeout` duration / the `jobs.queues` seq-only visitor), not
22755        // because of their module. Each still also appears as a bare root leaf
22756        // (recorded as a field of the root struct).
22757        let leaves = AutumnConfig::schema_leaf_paths();
22758        assert!(
22759            leaves.contains("server.port"),
22760            "server.port must be a schema leaf"
22761        );
22762        assert!(
22763            leaves.contains("server.host"),
22764            "server.host must be a schema leaf"
22765        );
22766        assert!(
22767            leaves.contains("database.url"),
22768            "database.url must be a schema leaf"
22769        );
22770        // Root-level sections also appear as single-segment leaves (recorded as
22771        // fields of the root struct), alongside their now-descended child keys.
22772        assert!(
22773            leaves.contains("security"),
22774            "security must appear as a root-level leaf"
22775        );
22776        assert!(
22777            leaves.contains("session"),
22778            "session must appear as a root-level leaf"
22779        );
22780    }
22781
22782    // ── ShardSlotAssignment / shards_auto_split / resolved_shard_assignments ──
22783
22784    #[test]
22785    fn shards_auto_split_true_when_all_slots_none() {
22786        let config = DatabaseConfig {
22787            shards: vec![
22788                shard("a", "postgres://a/app"),
22789                shard("b", "postgres://b/app"),
22790            ],
22791            ..Default::default()
22792        };
22793        assert!(config.shards_auto_split());
22794    }
22795
22796    #[test]
22797    fn shards_auto_split_false_when_no_shards() {
22798        assert!(!DatabaseConfig::default().shards_auto_split());
22799    }
22800
22801    #[test]
22802    fn shards_auto_split_false_when_any_shard_declares_slots() {
22803        let config = DatabaseConfig {
22804            shards: vec![
22805                shard_with_slots("a", "postgres://a/app", &["0-8191"]),
22806                shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
22807            ],
22808            ..Default::default()
22809        };
22810        assert!(!config.shards_auto_split());
22811    }
22812
22813    #[test]
22814    fn resolved_shard_assignments_two_shards() {
22815        let config = DatabaseConfig {
22816            shards: vec![
22817                shard("s0", "postgres://s0/app"),
22818                shard("s1", "postgres://s1/app"),
22819            ],
22820            ..Default::default()
22821        };
22822        let assignments = config
22823            .resolved_shard_assignments()
22824            .expect("two-shard auto-split should resolve");
22825        assert_eq!(assignments.len(), 2);
22826        assert_eq!(assignments[0].name, "s0");
22827        assert_eq!(assignments[0].ranges, "0-8191");
22828        assert_eq!(assignments[1].name, "s1");
22829        assert_eq!(assignments[1].ranges, "8192-16383");
22830    }
22831
22832    #[test]
22833    fn resolved_shard_assignments_three_shards() {
22834        let config = DatabaseConfig {
22835            shards: vec![
22836                shard("s0", "postgres://s0/app"),
22837                shard("s1", "postgres://s1/app"),
22838                shard("s2", "postgres://s2/app"),
22839            ],
22840            ..Default::default()
22841        };
22842        let assignments = config
22843            .resolved_shard_assignments()
22844            .expect("three-shard auto-split should resolve");
22845        assert_eq!(assignments.len(), 3);
22846        assert_eq!(assignments[0].ranges, "0-5461");
22847        assert_eq!(assignments[1].ranges, "5462-10922");
22848        assert_eq!(assignments[2].ranges, "10923-16383");
22849    }
22850
22851    // ── check_stored_slot_map ──────────────────────────────────────────────────
22852
22853    fn assignment(name: &str, ranges: &str) -> ShardSlotAssignment {
22854        ShardSlotAssignment {
22855            name: name.to_owned(),
22856            ranges: ranges.to_owned(),
22857        }
22858    }
22859
22860    #[test]
22861    fn check_stored_slot_map_explicit_mode_always_ok() {
22862        // Even with a wildly different stored map, explicit mode is never blocked.
22863        let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22864        let stored = vec![
22865            assignment("s0", "0-5460"),
22866            assignment("s1", "5461-10922"),
22867            assignment("s2", "10923-16383"),
22868        ];
22869        assert!(check_stored_slot_map(false, &computed, Some(&stored)).is_ok());
22870    }
22871
22872    #[test]
22873    fn check_stored_slot_map_first_boot_no_stored_ok() {
22874        let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22875        assert!(check_stored_slot_map(true, &computed, None).is_ok());
22876    }
22877
22878    #[test]
22879    fn check_stored_slot_map_matching_map_ok() {
22880        let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22881        // Order-insensitive: stored in reverse order still matches.
22882        let stored = vec![assignment("s1", "8192-16383"), assignment("s0", "0-8191")];
22883        assert!(check_stored_slot_map(true, &computed, Some(&stored)).is_ok());
22884    }
22885
22886    #[test]
22887    fn check_stored_slot_map_mismatch_two_to_three_shards_returns_err() {
22888        let computed = vec![
22889            assignment("s0", "0-5460"),
22890            assignment("s1", "5461-10922"),
22891            assignment("s2", "10923-16383"),
22892        ];
22893        let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22894        let err = check_stored_slot_map(true, &computed, Some(&stored))
22895            .expect_err("3-shard auto-split vs 2-shard stored map must fail");
22896        assert!(err.contains("shard slot map mismatch"), "message: {err}");
22897        assert!(err.contains("3 shards"), "message: {err}");
22898        assert!(err.contains("2 shards"), "message: {err}");
22899    }
22900
22901    #[test]
22902    fn check_stored_slot_map_mismatch_shard_rename_returns_err() {
22903        let computed = vec![
22904            assignment("alpha", "0-8191"),
22905            assignment("beta", "8192-16383"),
22906        ];
22907        let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22908        let err = check_stored_slot_map(true, &computed, Some(&stored))
22909            .expect_err("renamed shards must be detected as mismatch");
22910        assert!(err.contains("shard slot map mismatch"), "message: {err}");
22911        assert!(
22912            err.contains("alpha"),
22913            "message must name computed shards: {err}"
22914        );
22915        assert!(err.contains("s0"), "message must name stored shards: {err}");
22916    }
22917
22918    // ── Process role (#1613) ────────────────────────────────────────────────
22919
22920    #[test]
22921    fn process_role_default_is_combined() {
22922        assert_eq!(ProcessRole::default(), ProcessRole::Combined);
22923        assert_eq!(AutumnConfig::default().role, ProcessRole::Combined);
22924    }
22925
22926    #[test]
22927    fn process_role_from_env_value_accepts_aliases_case_insensitively() {
22928        for v in [
22929            "combined",
22930            "COMBINED",
22931            "  all ",
22932            "web_and_worker",
22933            "server_and_worker",
22934        ] {
22935            assert_eq!(
22936                ProcessRole::from_env_value(v),
22937                Some(ProcessRole::Combined),
22938                "{v}"
22939            );
22940        }
22941        for v in ["web", "Web", " SERVER ", "http"] {
22942            assert_eq!(
22943                ProcessRole::from_env_value(v),
22944                Some(ProcessRole::Web),
22945                "{v}"
22946            );
22947        }
22948        for v in ["worker", "WORKER", " jobs ", "worker_only"] {
22949            assert_eq!(
22950                ProcessRole::from_env_value(v),
22951                Some(ProcessRole::Worker),
22952                "{v}"
22953            );
22954        }
22955        for v in ["", "webby", "workers", "scheduler", "both"] {
22956            assert_eq!(ProcessRole::from_env_value(v), None, "{v}");
22957        }
22958    }
22959
22960    #[test]
22961    fn process_role_as_str_round_trips_through_from_env_value() {
22962        for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
22963            assert_eq!(ProcessRole::from_env_value(role.as_str()), Some(role));
22964        }
22965    }
22966
22967    // Issue #1864: named so ACME's spawn site reads as intent rather than an
22968    // inline `matches!` against the enum.
22969    #[test]
22970    fn scheduler_backend_is_fleet_distributed_only_for_postgres() {
22971        assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
22972        assert!(SchedulerBackend::Postgres.is_fleet_distributed());
22973    }
22974
22975    #[test]
22976    fn process_role_serves_http_and_runs_workers_truth_table() {
22977        assert!(ProcessRole::Combined.serves_http());
22978        assert!(ProcessRole::Combined.runs_workers());
22979        assert!(ProcessRole::Web.serves_http());
22980        assert!(!ProcessRole::Web.runs_workers());
22981        assert!(!ProcessRole::Worker.serves_http());
22982        assert!(ProcessRole::Worker.runs_workers());
22983    }
22984
22985    #[test]
22986    fn process_role_deserializes_from_toml() {
22987        let web: AutumnConfig = toml::from_str("role = \"web\"\n").expect("web role");
22988        assert_eq!(web.role, ProcessRole::Web);
22989        let worker: AutumnConfig = toml::from_str("role = \"worker\"\n").expect("worker role");
22990        assert_eq!(worker.role, ProcessRole::Worker);
22991        let combined: AutumnConfig =
22992            toml::from_str("role = \"combined\"\n").expect("combined role");
22993        assert_eq!(combined.role, ProcessRole::Combined);
22994        // Serde alias also works.
22995        let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
22996        assert_eq!(aliased.role, ProcessRole::Combined);
22997        // Absent → default.
22998        let absent: AutumnConfig = toml::from_str("").expect("empty config");
22999        assert_eq!(absent.role, ProcessRole::Combined);
23000    }
23001
23002    #[test]
23003    fn split_role_requires_durable_backend_truth_table() {
23004        // Combined is always fine (enqueues and drains in one process), even on
23005        // the in-process local backend.
23006        assert!(!split_role_requires_durable_backend(
23007            ProcessRole::Combined,
23008            "local"
23009        ));
23010        assert!(!split_role_requires_durable_backend(
23011            ProcessRole::Combined,
23012            "postgres"
23013        ));
23014        // Split roles on any backend that falls through to the per-process local
23015        // runtime are invalid: the literal `local`, a typo like `postgresql`, a
23016        // blank backend, or any other unknown value.
23017        assert!(split_role_requires_durable_backend(
23018            ProcessRole::Web,
23019            "local"
23020        ));
23021        assert!(split_role_requires_durable_backend(
23022            ProcessRole::Worker,
23023            "local"
23024        ));
23025        assert!(split_role_requires_durable_backend(
23026            ProcessRole::Web,
23027            "postgresql"
23028        ));
23029        assert!(split_role_requires_durable_backend(ProcessRole::Web, ""));
23030        assert!(split_role_requires_durable_backend(
23031            ProcessRole::Web,
23032            "unknown"
23033        ));
23034        // The match is exact (mirroring `start_runtime`'s dispatch), so a
23035        // case-variant like `LOCAL` is likewise a non-durable fall-through.
23036        assert!(split_role_requires_durable_backend(
23037            ProcessRole::Web,
23038            "LOCAL"
23039        ));
23040        // Split roles on the recognized durable backends are fine.
23041        assert!(!split_role_requires_durable_backend(
23042            ProcessRole::Web,
23043            "postgres"
23044        ));
23045        assert!(!split_role_requires_durable_backend(
23046            ProcessRole::Worker,
23047            "redis"
23048        ));
23049        // The SQLite queue is a table two processes on one host share, so it
23050        // backs a split role too (issue #1907).
23051        assert!(!split_role_requires_durable_backend(
23052            ProcessRole::Web,
23053            "sqlite"
23054        ));
23055        assert!(!split_role_requires_durable_backend(
23056            ProcessRole::Worker,
23057            "sqlite"
23058        ));
23059    }
23060
23061    /// Issue #1907: a split role on the sqlite queue needs a FILE-backed
23062    /// database. An in-memory target is private to each process, so the web
23063    /// replica would enqueue where no worker can look.
23064    #[test]
23065    fn split_role_on_sqlite_requires_a_file_backed_database() {
23066        // Every in-memory spelling is refused for a split role.
23067        for url in [
23068            "sqlite::memory:",
23069            "sqlite://:memory:",
23070            ":memory:",
23071            "file::memory:",
23072            "file::memory:?cache=shared",
23073            "sqlite:file::memory:?cache=shared",
23074            "sqlite://app.db?mode=memory",
23075            // A bare scheme names no file: `normalize_sqlite_target` turns
23076            // both of these into `:memory:`.
23077            "sqlite://",
23078            "sqlite:",
23079        ] {
23080            assert!(
23081                is_in_memory_sqlite_target(url),
23082                "{url} must read as in-memory"
23083            );
23084            assert!(
23085                split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
23086                "a split role on {url} must be refused"
23087            );
23088        }
23089
23090        // A file target is exactly what makes the split valid.
23091        for url in ["sqlite:///var/lib/app.db", "sqlite://./app.db", "app.db"] {
23092            assert!(
23093                !is_in_memory_sqlite_target(url),
23094                "{url} must read as file-backed"
23095            );
23096            assert!(
23097                !split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
23098                "a split role on {url} is valid"
23099            );
23100        }
23101
23102        // The combined role shares nothing, so it is fine either way.
23103        assert!(!split_role_requires_file_backed_sqlite(
23104            ProcessRole::Combined,
23105            "sqlite",
23106            Some("sqlite::memory:")
23107        ));
23108        // Other backends do not answer to this rule.
23109        assert!(!split_role_requires_file_backed_sqlite(
23110            ProcessRole::Worker,
23111            "postgres",
23112            Some("sqlite::memory:")
23113        ));
23114        // No database configured fails earlier, with its own error.
23115        assert!(!split_role_requires_file_backed_sqlite(
23116            ProcessRole::Worker,
23117            "sqlite",
23118            None
23119        ));
23120    }
23121
23122    /// Issue #1907: the single-host lease backend is selectable, aliased, and
23123    /// reports itself as fleet-distributed.
23124    #[test]
23125    fn scheduler_backend_sqlite_is_selectable_and_fleet_distributed() {
23126        assert_eq!(
23127            SchedulerBackend::from_env_value("sqlite"),
23128            Some(SchedulerBackend::Sqlite)
23129        );
23130        assert_eq!(
23131            SchedulerBackend::from_env_value(" SINGLE_HOST "),
23132            Some(SchedulerBackend::Sqlite)
23133        );
23134        assert_eq!(SchedulerBackend::from_env_value("nonsense"), None);
23135        assert!(SchedulerBackend::Sqlite.is_fleet_distributed());
23136        assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
23137
23138        let config: AutumnConfig =
23139            toml::from_str("[scheduler]\nbackend = \"sqlite\"\n").expect("sqlite backend");
23140        assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
23141        let aliased: AutumnConfig =
23142            toml::from_str("[scheduler]\nbackend = \"single_host\"\n").expect("single_host alias");
23143        assert_eq!(aliased.scheduler.backend, SchedulerBackend::Sqlite);
23144
23145        let env = MockEnv::new().with("AUTUMN_SCHEDULER__BACKEND", "sqlite");
23146        let mut config = AutumnConfig::default();
23147        config.apply_env_overrides_with_env(&env);
23148        assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
23149    }
23150
23151    /// Issue #1907: `[jobs.sqlite]` carries the durable queue's knobs and
23152    /// defaults to values an app never has to set.
23153    #[test]
23154    fn jobs_sqlite_section_parses_with_defaults() {
23155        let defaults = JobSqliteConfig::default();
23156        assert_eq!(defaults.visibility_timeout_ms, 30_000);
23157        assert_eq!(defaults.poll_interval_ms, 250);
23158
23159        let config: AutumnConfig = toml::from_str(
23160            "[jobs]\nbackend = \"sqlite\"\n\n[jobs.sqlite]\nvisibility_timeout_ms = 5000\n\
23161             poll_interval_ms = 50\n",
23162        )
23163        .expect("sqlite job section");
23164        assert_eq!(config.jobs.backend, "sqlite");
23165        assert_eq!(config.jobs.sqlite.visibility_timeout_ms, 5_000);
23166        assert_eq!(config.jobs.sqlite.poll_interval_ms, 50);
23167
23168        // Omitting the section keeps the defaults.
23169        let bare: AutumnConfig =
23170            toml::from_str("[jobs]\nbackend = \"sqlite\"\n").expect("bare jobs section");
23171        assert_eq!(bare.jobs.sqlite.visibility_timeout_ms, 30_000);
23172    }
23173
23174    #[test]
23175    fn autumn_role_env_override_sets_role() {
23176        let env = MockEnv::new().with("AUTUMN_ROLE", "worker");
23177        let mut config = AutumnConfig::default();
23178        config.apply_env_overrides_with_env(&env);
23179        assert_eq!(config.role, ProcessRole::Worker);
23180
23181        let env = MockEnv::new().with("AUTUMN_ROLE", "  WEB ");
23182        let mut config = AutumnConfig::default();
23183        config.apply_env_overrides_with_env(&env);
23184        assert_eq!(config.role, ProcessRole::Web);
23185    }
23186
23187    #[test]
23188    fn autumn_role_env_override_ignores_invalid_value_keeping_default() {
23189        let env = MockEnv::new().with("AUTUMN_ROLE", "nonsense");
23190        // Start from a non-default to prove invalid values do not reset it and
23191        // do not force it either — they leave the current value untouched.
23192        let mut config = AutumnConfig {
23193            role: ProcessRole::Worker,
23194            ..Default::default()
23195        };
23196        config.apply_env_overrides_with_env(&env);
23197        assert_eq!(config.role, ProcessRole::Worker);
23198
23199        // And from the default, an invalid value keeps Combined.
23200        let mut config = AutumnConfig::default();
23201        config.apply_env_overrides_with_env(&env);
23202        assert_eq!(config.role, ProcessRole::Combined);
23203    }
23204}