tephra-server 0.4.1

Synchronous, thread-per-connection TCP server exposing a tephra event store over the length-prefixed protobuf protocol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! Layered server configuration.
//!
//! Sources are merged in ascending precedence: built-in defaults, then an optional TOML
//! config file (`--config`), then `TEPHRA__*` environment variables, then a small set of
//! command-line flags. A later source overrides an earlier one.
//!
//! The command line intentionally carries only the launch essentials (`--bind`,
//! `--data-dir`, `--log`, plus `--config`): where the server runs and how to reach it, the
//! things typed per invocation. The full performance and memory tuning surface lives in the
//! config file and environment so it stays declarative and reviewable rather than a wall of
//! flags. Deliberately internal knobs (the paranoid tips cross-check, record-framing sizes)
//! are not exposed at any tier.

use std::error::Error;
use std::time::Duration;

use argh::FromArgs;
use config::{Config, Environment, File, FileFormat};
use serde::Deserialize;
use tephra::log::set::SegmentConfig;
use tephra::read::ReadConfig;
use tephra::writer::WriterConfig;
use tephra_proto::DEFAULT_MAX_FRAME_LEN;
use tephra_server::ServerConfig;

/// tephra event store server: opens a store on disk and serves it over TCP.
#[derive(Debug, FromArgs)]
pub struct Args {
    /// path to a TOML config file (all tuning lives here or in TEPHRA__* env vars)
    #[argh(option, short = 'c')]
    pub config: Option<String>,

    /// address to bind, e.g. 127.0.0.1:9000
    #[argh(option, short = 'b')]
    pub bind: Option<String>,

    /// data directory for the event store
    #[argh(option, short = 'd')]
    pub data_dir: Option<String>,

    /// tracing filter, overriding TEPHRA_LOG (e.g. "info" or "tephra=debug")
    #[argh(option, short = 'l')]
    pub log: Option<String>,

    /// probe a running server at the configured bind address, then exit 0 if healthy or 1 if not
    #[argh(switch)]
    pub healthcheck: bool,
}

/// The fully-resolved server configuration.
///
/// Field names double as config-file keys and (upper-cased, `__`-joined) environment
/// variable names, so `writer.max_batch_bytes` is `TEPHRA__WRITER__MAX_BATCH_BYTES`.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
    /// Address the TCP listener binds.
    pub bind: String,
    /// Directory holding the log (and index) segment files.
    pub data_dir: String,
    /// Tracing filter. `None` falls back to `TEPHRA_LOG`, then to `info`.
    pub log: Option<String>,
    pub segment: SegmentSettings,
    pub writer: WriterSettings,
    pub read: ReadSettings,
    pub server: ServerSettings,
    pub metrics: MetricsSettings,
    pub tls: TlsSettings,
    pub auth: AuthSettings,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            bind: "127.0.0.1:9000".to_string(),
            data_dir: "tephra-data".to_string(),
            log: None,
            segment: SegmentSettings::default(),
            writer: WriterSettings::default(),
            read: ReadSettings::default(),
            server: ServerSettings::default(),
            metrics: MetricsSettings::default(),
            tls: TlsSettings::default(),
            auth: AuthSettings::default(),
        }
    }
}

/// Prometheus `/metrics` endpoint. Served on its own port, separate from `bind`.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetricsSettings {
    /// Address for the `/metrics` HTTP endpoint, e.g. `127.0.0.1:9100`. `None` disables it.
    pub bind: Option<String>,
}

/// TLS transport. A certificate and key together enable TLS; both absent leaves the server
/// plaintext. Exactly one set is a configuration error.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TlsSettings {
    /// Path to the PEM certificate chain. Set with `key` to serve TLS.
    pub cert: Option<String>,
    /// Path to the PEM private key.
    pub key: Option<String>,
}

/// Bearer-token authentication. Any configured token in a connection's opening Hello is accepted;
/// an empty `tokens` list leaves the server open (no authentication). Tokens are secrets, so they
/// require TLS unless `allow_insecure` is set (see [`Settings::validate`]).
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AuthSettings {
    /// The accepted tokens, each a table so scopes can be added later without a config-format
    /// change. Multiple tokens allow zero-downtime rotation: add the new one, roll clients over,
    /// then drop the old.
    pub tokens: Vec<TokenSettings>,
    /// Permit tokens over a plaintext listener, for a deployment that terminates TLS at a proxy or
    /// mesh before tephra. Off by default: a bearer secret should not cross an unencrypted hop.
    pub allow_insecure: bool,
}

/// One accepted token. A table (rather than a bare string) so an `access` scope, a name, or tag
/// restrictions can be added additively in a later step.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TokenSettings {
    /// The bearer token a client presents in its Hello.
    pub token: String,
}

/// Log-segment sizing options.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SegmentSettings {
    /// Total size of each segment file in bytes, including its header.
    pub size: usize,
}

impl Default for SegmentSettings {
    fn default() -> Self {
        SegmentSettings {
            size: 256 * 1024 * 1024,
        }
    }
}

/// Write-coordinator tuning: backpressure, group-commit sizing, and the tips memory bound.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WriterSettings {
    /// Bounded request-queue depth. When full, an append blocks (backpressure).
    pub queue_capacity: usize,
    /// Most requests folded into one group-committed batch.
    pub max_batch_records: usize,
    /// Byte budget for one batch. Clamped down to the segment capacity at startup so it can
    /// never exceed a shrunk `segment.size`.
    pub max_batch_bytes: usize,
    /// Recent-position window width for the durable tips map (a memory bound only).
    pub tips_window: u64,
    /// Resolve the append-condition durable arm with the log scan instead of the index
    /// existence check. An operational escape hatch: the log is the source of truth, so the
    /// scan is always safe, just slower.
    pub condition_force_scan: bool,
}

impl Default for WriterSettings {
    fn default() -> Self {
        WriterSettings {
            queue_capacity: 16384,
            max_batch_records: 2048,
            max_batch_bytes: 8 * 1024 * 1024,
            tips_window: 1_000_000,
            condition_force_scan: false,
        }
    }
}

/// Read-path planner tuning.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ReadSettings {
    /// The planner's `K`: the index is chosen only when the post-pruning range is at least
    /// `scan_bias` times the estimated result count, so larger values bias toward scanning at
    /// the margin. Changes only which correct path runs, never the answer.
    pub scan_bias: u32,
}

impl Default for ReadSettings {
    fn default() -> Self {
        ReadSettings { scan_bias: 4 }
    }
}

/// TCP server tuning, grouped by concern into nested tables. Durations are expressed as integers
/// with an explicit unit suffix so they stay natural TOML/env scalars (there is no bare `Duration`
/// on the wire).
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ServerSettings {
    /// Largest single frame accepted or produced, in bytes.
    pub max_frame_len: u32,
    pub reads: ReadsSettings,
    pub subscriptions: SubscriptionsSettings,
    pub backpressure: BackpressureSettings,
    pub limits: LimitsSettings,
    pub keepalive: KeepaliveSettings,
    pub timeouts: TimeoutsSettings,
}

impl Default for ServerSettings {
    fn default() -> Self {
        ServerSettings {
            max_frame_len: DEFAULT_MAX_FRAME_LEN,
            reads: ReadsSettings::default(),
            subscriptions: SubscriptionsSettings::default(),
            backpressure: BackpressureSettings::default(),
            limits: LimitsSettings::default(),
            keepalive: KeepaliveSettings::default(),
            timeouts: TimeoutsSettings::default(),
        }
    }
}

/// Streamed-read flush thresholds and the shared read-worker pool.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ReadsSettings {
    /// A streamed read (or subscription) is flushed as a frame once it holds this many events.
    pub batch_events: usize,
    /// A streamed read (or subscription) is flushed as a frame once its buffered events reach this
    /// many bytes.
    pub batch_bytes: usize,
    /// Reusable worker threads in the shared read pool. `0` means one per logical CPU.
    pub worker_threads: usize,
}

impl Default for ReadsSettings {
    fn default() -> Self {
        ReadsSettings {
            batch_events: 1024,
            batch_bytes: 512 * 1024,
            worker_threads: 0,
        }
    }
}

/// Live-subscription pacing and the per-connection subscription cap.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SubscriptionsSettings {
    /// How often an idle subscription's blocking wait wakes to re-check server shutdown, in
    /// milliseconds.
    pub wait_tick_ms: u64,
    /// Most live subscriptions a single connection may hold at once; one over the limit is rejected.
    pub max_concurrent: usize,
}

impl Default for SubscriptionsSettings {
    fn default() -> Self {
        SubscriptionsSettings {
            wait_tick_ms: 250,
            max_concurrent: 64,
        }
    }
}

/// Per-connection backpressure bounds.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackpressureSettings {
    /// Per-connection in-flight budget, applied separately to appends and reads: this many appends
    /// awaiting a reply (then the reader backpressures), and this many concurrent reads plus this
    /// many queued for a slot (then a further read is rejected, never blocking the reader).
    pub max_inflight_per_conn: usize,
    /// Depth of a connection's outbound bulk frame queue: read and subscription frames buffered
    /// before a slow client applies backpressure. Small control frames use a separate priority lane.
    pub frame_queue_depth: usize,
}

impl Default for BackpressureSettings {
    fn default() -> Self {
        BackpressureSettings {
            max_inflight_per_conn: 256,
            frame_queue_depth: 256,
        }
    }
}

/// Server-wide connection limits.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LimitsSettings {
    /// Most connections served at once, across all clients. A connection over the cap is closed
    /// immediately, before any request is read. `0` means unlimited (an explicit opt-out).
    pub max_connections: usize,
}

impl Default for LimitsSettings {
    fn default() -> Self {
        LimitsSettings {
            max_connections: 1024,
        }
    }
}

/// TCP keepalive timers.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct KeepaliveSettings {
    /// Idle time before the first keepalive probe on an accepted connection, in seconds. The OS
    /// default (~2h on Linux) is too long to reap a silently-dead subscription promptly.
    pub idle_secs: u64,
    /// Interval between keepalive probes once they start, in seconds.
    pub interval_secs: u64,
}

impl Default for KeepaliveSettings {
    fn default() -> Self {
        KeepaliveSettings {
            idle_secs: 60,
            interval_secs: 15,
        }
    }
}

/// Connection-reaping timeouts, in seconds. `0` disables the corresponding reaper.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TimeoutsSettings {
    /// A partial request frame must finish within this long once its first byte arrives, before the
    /// connection is reaped (slow-loris trickle defense).
    pub incomplete_frame_secs: u64,
    /// A freshly accepted connection must send its first complete frame within this long. Off by
    /// default: a pooling client may hold a connection open before its first request.
    pub handshake_secs: u64,
    /// A connection with no request in flight and no live subscription may sit idle this long. Off
    /// by default, for the same pooling reason as `handshake_secs`.
    pub idle_secs: u64,
}

impl Default for TimeoutsSettings {
    fn default() -> Self {
        TimeoutsSettings {
            incomplete_frame_secs: 30,
            handshake_secs: 0,
            idle_secs: 0,
        }
    }
}

impl Settings {
    /// The segment config for opening the store.
    pub fn segment_config(&self) -> SegmentConfig {
        SegmentConfig::new(self.segment.size)
    }

    /// The write-coordinator config. `verify_tips` is deliberately never operator-settable, so
    /// it stays `false` here.
    pub fn writer_config(&self) -> WriterConfig {
        WriterConfig {
            queue_capacity: self.writer.queue_capacity,
            max_batch_records: self.writer.max_batch_records,
            max_batch_bytes: self.writer.max_batch_bytes,
            tips_window: self.writer.tips_window,
            verify_tips: false,
            condition_force_scan: self.writer.condition_force_scan,
            read: ReadConfig {
                scan_bias: self.read.scan_bias,
            },
        }
    }

    /// The TCP server config.
    pub fn server_config(&self) -> ServerConfig {
        let server = &self.server;
        ServerConfig {
            max_frame_len: server.max_frame_len,
            read_batch_events: server.reads.batch_events,
            read_batch_bytes: server.reads.batch_bytes,
            subscribe_wait_tick: Duration::from_millis(server.subscriptions.wait_tick_ms),
            max_inflight_requests_per_conn: server.backpressure.max_inflight_per_conn,
            max_concurrent_subscriptions: server.subscriptions.max_concurrent,
            read_worker_threads: server.reads.worker_threads,
            frame_queue_depth: server.backpressure.frame_queue_depth,
            keepalive_idle: Duration::from_secs(server.keepalive.idle_secs),
            keepalive_interval: Duration::from_secs(server.keepalive.interval_secs),
            max_connections: server.limits.max_connections,
            incomplete_frame_timeout: Duration::from_secs(server.timeouts.incomplete_frame_secs),
            handshake_timeout: Duration::from_secs(server.timeouts.handshake_secs),
            idle_timeout: Duration::from_secs(server.timeouts.idle_secs),
        }
    }

    /// Rejects values the write coordinator would otherwise assert on at startup, so a config
    /// typo is a graceful error rather than a panic. A count of zero is never meaningful, so it
    /// is rejected outright (unlike `max_batch_bytes`, whose valid default can exceed a shrunk
    /// `segment.size` and so is clamped, not rejected, once the capacity is known).
    fn validate(&self) -> Result<(), String> {
        if self.writer.queue_capacity == 0 {
            return Err("writer.queue_capacity must be at least 1".to_string());
        }
        if self.writer.max_batch_records == 0 {
            return Err("writer.max_batch_records must be at least 1".to_string());
        }
        // A zero wait tick would make the subscription's bounded wait return immediately and
        // busy-spin; zero keepalive timers are meaningless. Reject rather than let either
        // degrade silently.
        if self.server.subscriptions.wait_tick_ms == 0 {
            return Err("server.subscriptions.wait_tick_ms must be at least 1".to_string());
        }
        // A zero budget would wedge the connection (no request could ever acquire a permit); a
        // zero frame queue is a rendezvous channel, not the intended bound. Reject both.
        if self.server.backpressure.max_inflight_per_conn == 0 {
            return Err("server.backpressure.max_inflight_per_conn must be at least 1".to_string());
        }
        if self.server.subscriptions.max_concurrent == 0 {
            return Err("server.subscriptions.max_concurrent must be at least 1".to_string());
        }
        if self.server.backpressure.frame_queue_depth == 0 {
            return Err("server.backpressure.frame_queue_depth must be at least 1".to_string());
        }
        if self.server.keepalive.idle_secs == 0 {
            return Err("server.keepalive.idle_secs must be at least 1".to_string());
        }
        if self.server.keepalive.interval_secs == 0 {
            return Err("server.keepalive.interval_secs must be at least 1".to_string());
        }
        // A certificate without a key (or the reverse) cannot serve TLS and is almost certainly a
        // mistake; reject it rather than silently fall back to plaintext.
        if self.tls.cert.is_some() != self.tls.key.is_some() {
            return Err("tls.cert and tls.key must be set together".to_string());
        }
        // An empty token is never a valid secret and would silently accept unauthenticated peers.
        if self.auth.tokens.iter().any(|t| t.token.is_empty()) {
            return Err("auth.tokens entries must have a non-empty token".to_string());
        }
        // Tokens are bearer secrets: refuse to serve them over plaintext unless the operator has
        // explicitly opted in (TLS terminated at a proxy/mesh in front of tephra).
        let tls_enabled = self.tls.cert.is_some() && self.tls.key.is_some();
        if !self.auth.tokens.is_empty() && !tls_enabled && !self.auth.allow_insecure {
            return Err(
                "auth.tokens require tls; set tls.cert and tls.key, or auth.allow_insecure = true"
                    .to_string(),
            );
        }
        Ok(())
    }

    /// The configured bearer tokens, if any. `None` leaves the server open (no authentication).
    /// Owned because the sole consumer, `AuthConfig::new`, hashes and keeps them.
    pub fn auth_tokens(&self) -> Option<Vec<String>> {
        if self.auth.tokens.is_empty() {
            return None;
        }
        Some(self.auth.tokens.iter().map(|t| t.token.clone()).collect())
    }

    /// The first configured token, borrowed, for the healthcheck probe (which needs one token, not
    /// the whole set). `None` when no tokens are configured.
    pub fn first_auth_token(&self) -> Option<&str> {
        self.auth.tokens.first().map(|t| t.token.as_str())
    }
}

/// Builds the effective settings from defaults, the optional config file, the `TEPHRA__*`
/// environment, and finally the command-line overrides.
pub fn load(args: &Args) -> Result<Settings, Box<dyn Error>> {
    let mut builder = Config::builder();
    if let Some(path) = &args.config {
        // Explicit path: a missing or malformed file is an error, not a silent skip.
        builder = builder.add_source(File::new(path, FileFormat::Toml).required(true));
    }
    builder = builder.add_source(
        Environment::with_prefix("TEPHRA")
            .prefix_separator("__")
            .separator("__")
            .try_parsing(true),
    );

    let mut settings: Settings = builder.build()?.try_deserialize()?;

    // The command line wins over the file and the environment.
    if let Some(bind) = &args.bind {
        settings.bind = bind.clone();
    }
    if let Some(data_dir) = &args.data_dir {
        settings.data_dir = data_dir.clone();
    }
    if args.log.is_some() {
        settings.log = args.log.clone();
    }

    settings.validate()?;
    Ok(settings)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn no_args() -> Args {
        Args {
            config: None,
            bind: None,
            data_dir: None,
            log: None,
            healthcheck: false,
        }
    }

    #[test]
    fn defaults_match_the_library_defaults() {
        // No file, no matching env: every field falls through to its serde default, which must
        // mirror the library's own `Default` impls so behaviour is identical to the old binary.
        let settings = load(&no_args()).unwrap();
        let writer = settings.writer_config();
        let library_default = WriterConfig::default();
        assert_eq!(writer.queue_capacity, library_default.queue_capacity);
        assert_eq!(writer.max_batch_records, library_default.max_batch_records);
        assert_eq!(writer.max_batch_bytes, library_default.max_batch_bytes);
        assert_eq!(writer.tips_window, library_default.tips_window);
        assert!(!writer.verify_tips);
        assert_eq!(writer.read.scan_bias, ReadConfig::default().scan_bias);

        let server = settings.server_config();
        let server_default = ServerConfig::default();
        assert_eq!(server.max_frame_len, server_default.max_frame_len);
        assert_eq!(server.read_batch_events, server_default.read_batch_events);
        assert_eq!(server.read_batch_bytes, server_default.read_batch_bytes);
        assert_eq!(
            server.subscribe_wait_tick,
            server_default.subscribe_wait_tick
        );
        assert_eq!(
            server.max_inflight_requests_per_conn,
            server_default.max_inflight_requests_per_conn
        );
        assert_eq!(
            server.max_concurrent_subscriptions,
            server_default.max_concurrent_subscriptions
        );
        assert_eq!(
            server.read_worker_threads,
            server_default.read_worker_threads
        );
        assert_eq!(server.frame_queue_depth, server_default.frame_queue_depth);
        assert_eq!(server.keepalive_idle, server_default.keepalive_idle);
        assert_eq!(server.keepalive_interval, server_default.keepalive_interval);
        assert_eq!(server.max_connections, server_default.max_connections);
        assert_eq!(
            server.incomplete_frame_timeout,
            server_default.incomplete_frame_timeout
        );
        assert_eq!(server.handshake_timeout, server_default.handshake_timeout);
        assert_eq!(server.idle_timeout, server_default.idle_timeout);

        assert_eq!(settings.bind, "127.0.0.1:9000");
        assert_eq!(settings.data_dir, "tephra-data");
    }

    #[test]
    fn example_toml_mirrors_the_defaults() {
        // `tephra.example.toml` documents itself as "every value shown is the built-in default",
        // and nothing else pins that promise. Deserialize the file on its own (no env, no CLI) and
        // assert it round-trips to `Settings::default()`, so any drift between the file and a
        // changed default fails here. `deny_unknown_fields` also catches a renamed or stray key.
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tephra.example.toml");
        let settings: Settings = Config::builder()
            .add_source(File::new(path, FileFormat::Toml).required(true))
            .build()
            .unwrap()
            .try_deserialize()
            .unwrap();
        assert_eq!(settings, Settings::default());
    }

    #[test]
    fn cli_overrides_win() {
        let args = Args {
            config: None,
            bind: Some("0.0.0.0:7000".to_string()),
            data_dir: Some("/var/lib/tephra".to_string()),
            log: Some("tephra=debug".to_string()),
            healthcheck: false,
        };
        let settings = load(&args).unwrap();
        assert_eq!(settings.bind, "0.0.0.0:7000");
        assert_eq!(settings.data_dir, "/var/lib/tephra");
        assert_eq!(settings.log.as_deref(), Some("tephra=debug"));
    }

    #[test]
    fn zero_writer_counts_are_rejected_not_panicked() {
        // The coordinator asserts these are at least 1; validation must turn a config typo into a
        // graceful error rather than let the assert abort the process.
        let mut settings = Settings::default();
        settings.writer.queue_capacity = 0;
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.writer.max_batch_records = 0;
        assert!(settings.validate().is_err());
    }

    #[test]
    fn zero_server_durations_are_rejected() {
        // A zero wait tick would busy-spin the subscription loop; zero keepalive timers are
        // meaningless. Each must be rejected at load time.
        let mut settings = Settings::default();
        settings.server.subscriptions.wait_tick_ms = 0;
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.server.keepalive.idle_secs = 0;
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.server.keepalive.interval_secs = 0;
        assert!(settings.validate().is_err());
    }

    #[test]
    fn zero_server_concurrency_counts_are_rejected() {
        // A zero budget would wedge a connection; a zero frame queue changes channel semantics.
        // Each must be rejected at load time rather than degrade at runtime.
        let mut settings = Settings::default();
        settings.server.backpressure.max_inflight_per_conn = 0;
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.server.subscriptions.max_concurrent = 0;
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.server.backpressure.frame_queue_depth = 0;
        assert!(settings.validate().is_err());
    }

    fn with_tls(mut settings: Settings) -> Settings {
        settings.tls.cert = Some("server.crt".to_string());
        settings.tls.key = Some("server.key".to_string());
        settings
    }

    fn token(value: &str) -> TokenSettings {
        TokenSettings {
            token: value.to_string(),
        }
    }

    #[test]
    fn auth_tokens_require_tls_unless_allow_insecure() {
        // Tokens over plaintext are rejected by default...
        let mut settings = Settings::default();
        settings.auth.tokens = vec![token("secret")];
        assert!(settings.validate().is_err());

        // ...accepted with TLS...
        let mut settings = with_tls(Settings::default());
        settings.auth.tokens = vec![token("secret")];
        assert!(settings.validate().is_ok());

        // ...and accepted over plaintext only with the explicit opt-out.
        let mut settings = Settings::default();
        settings.auth.tokens = vec![token("secret")];
        settings.auth.allow_insecure = true;
        assert!(settings.validate().is_ok());
    }

    #[test]
    fn empty_auth_token_is_rejected() {
        let mut settings = with_tls(Settings::default());
        settings.auth.tokens = vec![token("")];
        assert!(settings.validate().is_err());
    }

    #[test]
    fn no_auth_tokens_is_open_and_valid() {
        // The default (empty token list) is valid over plaintext and yields no auth config.
        let settings = Settings::default();
        assert!(settings.validate().is_ok());
        assert!(settings.auth_tokens().is_none());
    }

    #[test]
    fn auth_tokens_collects_configured_tokens() {
        let mut settings = with_tls(Settings::default());
        settings.auth.tokens = vec![token("alpha"), token("beta")];
        assert_eq!(
            settings.auth_tokens(),
            Some(vec!["alpha".to_string(), "beta".to_string()])
        );
    }

    #[test]
    fn tls_cert_and_key_must_be_set_together() {
        // Both unset (plaintext) and both set (TLS) are valid; exactly one is a misconfiguration.
        let mut settings = Settings::default();
        settings.tls.cert = Some("server.crt".to_string());
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.tls.key = Some("server.key".to_string());
        assert!(settings.validate().is_err());

        let mut settings = Settings::default();
        settings.tls.cert = Some("server.crt".to_string());
        settings.tls.key = Some("server.key".to_string());
        assert!(settings.validate().is_ok());
    }
}