This is an automated email from the ASF dual-hosted git repository.

viirya pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-gateway.git


The following commit(s) were added to refs/heads/main by this push:
     new 3d796c4  [SPARK-58858] Add scg-config crate
3d796c4 is described below

commit 3d796c446a6203445fb029e24bfae7897536fdef
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Wed Aug 19 23:23:49 2026 -0700

    [SPARK-58858] Add scg-config crate
    
    ### What changes were proposed in this pull request?
    
    Part of importing the Spark Connect Gateway reference implementation into
    `apache/spark-connect-gateway` as a series of small, bottom-up PRs (tracked 
in
    the do-not-merge reference PR #1).
    
    This adds the `scg-config` crate — the YAML configuration layer for the 
gateway.
    It defines the config schema (deserialized with serde) and the loader that 
reads
    and validates the gateway's YAML configuration at startup.
    
    `scg-config` is a leaf crate (no internal dependencies), so it compiles and
    tests on its own (45 unit tests covering the schema and loader). It is 
appended
    to the workspace `members` list and is consumed later by the gateway binary.
    
    ### Why are the changes needed?
    
    Implements the reference gateway from the approved SPIP (SPARK-58455), 
imported
    one reviewable piece at a time.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D 
warnings`,
    `cargo build --workspace`, and `cargo test --workspace` all pass locally
    (scg-config has 45 unit tests).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, co-authored with Claude Code.
    
    Closes #4 from viirya/import-03-config.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
 Cargo.lock               |   65 ++
 Cargo.toml               |    1 +
 crates/config/Cargo.toml |   35 +
 crates/config/src/lib.rs | 1631 ++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 1732 insertions(+)

diff --git a/Cargo.lock b/Cargo.lock
index a7b6d1d..c38a817 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -654,6 +654,22 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scg-config"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_yaml",
+ "tempfile",
+ "thiserror",
+]
+
 [[package]]
 name = "scg-genproto"
 version = "0.1.0"
@@ -681,6 +697,16 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
 [[package]]
 name = "serde_core"
 version = "1.0.229"
@@ -701,6 +727,19 @@ dependencies = [
  "syn 3.0.3",
 ]
 
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.8"
@@ -774,6 +813,26 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
 [[package]]
 name = "tokio"
 version = "1.53.1"
@@ -975,6 +1034,12 @@ version = "1.0.24"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
 [[package]]
 name = "want"
 version = "0.3.1"
diff --git a/Cargo.toml b/Cargo.toml
index 8484085..d2c44c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,6 +18,7 @@ resolver = "2"
 members = [
     "crates/genproto",
     "crates/routing",
+    "crates/config",
 ]
 
 [workspace.package]
diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml
new file mode 100644
index 0000000..782ea30
--- /dev/null
+++ b/crates/config/Cargo.toml
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-config"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "YAML configuration schema for the Spark Connect Gateway."
+publish = false
+
+[dependencies]
+serde = { workspace = true }
+serde_yaml = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
new file mode 100644
index 0000000..2ecc98e
--- /dev/null
+++ b/crates/config/src/lib.rs
@@ -0,0 +1,1631 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! YAML configuration for the gateway.
+//!
+//! Two equivalent forms are accepted for backend discovery:
+//!
+//! ```yaml
+//! # Shorthand — equivalent to a static-list `backend_discovery`:
+//! backends:
+//!   - "host1:15002"
+//!   - "host2:15002"
+//! ```
+//!
+//! ```yaml
+//! # Tagged static form:
+//! backend_discovery:
+//!   type: static
+//!   addresses: ["host1:15002", "host2:15002"]
+//! ```
+//!
+//! ```yaml
+//! # Tagged K8s form — watches an Endpoints object:
+//! backend_discovery:
+//!   type: k8s
+//!   namespace: spark-connect
+//!   service_name: spark-connect
+//!   port: 15002
+//! ```
+
+use serde::Deserialize;
+use std::path::Path;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ConfigError {
+    #[error("read config {path}: {source}")]
+    Io {
+        path: String,
+        #[source]
+        source: std::io::Error,
+    },
+    #[error("parse config {path}: {source}")]
+    Parse {
+        path: String,
+        #[source]
+        source: serde_yaml::Error,
+    },
+    #[error(
+        "config: must specify either `backends` or `backend_discovery` \
+         (omitting both is allowed only when \
+         `tenant_pools.on_unknown_tenant: reject` is set and at least \
+         one tenant override is configured)"
+    )]
+    NoDiscoverySource,
+    #[error("config: cannot specify both `backends` and `backend_discovery`")]
+    ConflictingDiscovery,
+    #[error("config: static backend list must contain at least one address")]
+    EmptyStatic,
+    #[error("config: backend token env var {name} is not set")]
+    TokenEnvMissing { name: String },
+    #[error("config: read backend token file {path}: {source}")]
+    TokenFile {
+        path: String,
+        #[source]
+        source: std::io::Error,
+    },
+    #[error("config: backend token resolved to an empty string")]
+    TokenEmpty,
+}
+
+/// One of the supported backend discovery sources.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum BackendDiscovery {
+    /// Fixed list of `host:port` addresses, configured at startup.
+    Static { addresses: Vec<String> },
+    /// Watch a Kubernetes Service's Endpoints object.
+    K8s {
+        namespace: String,
+        service_name: String,
+        port: u16,
+    },
+}
+
+/// Where a backend pre-shared token comes from. Backends started
+/// with `spark.connect.authenticate.token` (Spark 4.0+) require a
+/// matching `Bearer` credential on every request; the gateway
+/// presents it on the gateway→backend hop so that operators can
+/// hold the token *only* in the gateway and have the backend itself
+/// reject clients that try to bypass it.
+///
+/// `env` / `file` keep the token out of the YAML (and out of the
+/// ConfigMap the Helm chart renders it into); `inline` is for
+/// walkthroughs and tests.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum BackendTokenSource {
+    Inline { token: String },
+    Env { name: String },
+    File { path: String },
+}
+
+impl BackendTokenSource {
+    /// Resolve the token value. Reads the env var / file once, at
+    /// startup — the backend's own token is fixed for the server's
+    /// lifetime, so rotation implies restarting both sides anyway.
+    pub fn resolve(&self) -> Result<String, ConfigError> {
+        let token = match self {
+            Self::Inline { token } => token.clone(),
+            Self::Env { name } => std::env::var(name)
+                .map_err(|_| ConfigError::TokenEnvMissing { name: name.clone() 
})?,
+            Self::File { path } => std::fs::read_to_string(path)
+                .map_err(|e| ConfigError::TokenFile {
+                    path: path.clone(),
+                    source: e,
+                })?
+                .trim_end_matches(['\r', '\n'])
+                .to_string(),
+        };
+        if token.is_empty() {
+            return Err(ConfigError::TokenEmpty);
+        }
+        Ok(token)
+    }
+}
+
+/// Authentication configuration. Defaults to `none` so an unset
+/// `auth:` block lets every caller through as `user_id="anonymous"`
+/// — fine for trusted in-cluster networks, not for external exposure.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum AuthConfig {
+    /// No auth — anyone reaching the gateway is `user_id: anonymous`.
+    /// Acceptable on a trusted network; **not** for production.
+    #[default]
+    None,
+    /// Bearer-token allowlist. See [`scg-auth::token`].
+    Static { tokens: Vec<TokenEntry> },
+    /// Local-key JWT verification. See [`scg-auth::jwt`].
+    Jwt(JwtSettings),
+    /// Remote JWKS / OIDC verification. See [`scg-auth::oidc`].
+    Oidc(OidcSettings),
+}
+
+/// One entry in the static-token table — kept here (rather than only in
+/// `scg-auth`) so config files can describe auth without depending on
+/// the auth crate's serde shape.
+#[derive(Debug, Clone, Deserialize)]
+pub struct TokenEntry {
+    pub token: String,
+    pub user_id: String,
+    #[serde(default)]
+    pub tenant: Option<String>,
+    #[serde(default)]
+    pub groups: Vec<String>,
+}
+
+/// JWT verification settings; mirrors `scg_auth::jwt::JwtConfig`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct JwtSettings {
+    pub key: KeySource,
+    pub algorithms: Vec<String>,
+    #[serde(default)]
+    pub issuer: Option<String>,
+    #[serde(default)]
+    pub audience: Option<String>,
+    #[serde(default = "default_user_id_claim")]
+    pub user_id_claim: String,
+    #[serde(default)]
+    pub tenant_claim: Option<String>,
+    #[serde(default)]
+    pub groups_claim: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum KeySource {
+    PemFile { path: String },
+    PemInline { pem: String },
+    HmacSecret { secret: String },
+}
+
+/// OIDC verification settings; mirrors `scg_auth::oidc::OidcConfig`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct OidcSettings {
+    #[serde(default)]
+    pub jwks_url: Option<String>,
+    #[serde(default)]
+    pub discovery_url: Option<String>,
+    pub algorithms: Vec<String>,
+    #[serde(default)]
+    pub issuer: Option<String>,
+    #[serde(default)]
+    pub audience: Option<String>,
+    #[serde(default = "default_user_id_claim")]
+    pub user_id_claim: String,
+    #[serde(default)]
+    pub tenant_claim: Option<String>,
+    #[serde(default)]
+    pub groups_claim: Option<String>,
+    #[serde(default = "default_refresh_floor_secs")]
+    pub refresh_floor_secs: u64,
+}
+
+fn default_user_id_claim() -> String {
+    "sub".into()
+}
+fn default_refresh_floor_secs() -> u64 {
+    60
+}
+
+/// Where the gateway keeps its `(user_id, session_id) -> backend`
+/// affinity table. Default `memory` is single-replica only.
+/// `redis` is required for HA across multiple gateway replicas —
+/// without it, two replicas will pin the same session to different
+/// backends and Spark Connect's per-driver session state stops
+/// being consistent.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum AffinityStoreConfig {
+    #[default]
+    Memory,
+    Redis(RedisStoreSettings),
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct RedisStoreSettings {
+    /// `redis://` URL. Supports password (`redis://:pw@host:6379`)
+    /// and database index (`redis://host:6379/2`).
+    pub url: String,
+    /// Key prefix; lets multiple gateway deployments share a Redis
+    /// without colliding. Default `scg`.
+    #[serde(default = "default_redis_prefix")]
+    pub key_prefix: String,
+    /// TTL for `(user_id, session_id) -> backend` bindings (seconds).
+    /// Refreshed on every read.
+    #[serde(default = "default_session_ttl_secs")]
+    pub session_ttl_secs: u64,
+    /// TTL for `op_id -> backend` bindings (seconds).
+    #[serde(default = "default_op_ttl_secs")]
+    pub op_ttl_secs: u64,
+}
+
+fn default_redis_prefix() -> String {
+    "scg".into()
+}
+fn default_session_ttl_secs() -> u64 {
+    60 * 60
+}
+fn default_op_ttl_secs() -> u64 {
+    15 * 60
+}
+
+/// Audit logging configuration. Records security- and
+/// compliance-relevant events as structured `tracing` events with
+/// `target = "scg::audit"`. On by default — the per-event cost is
+/// negligible and the compliance value is high.
+#[derive(Debug, Clone, Deserialize)]
+pub struct AuditSettings {
+    /// Master switch. `true` (default) writes session lifecycle,
+    /// auth failures, and RPC errors to the audit stream.
+    #[serde(default = "default_audit_enabled")]
+    pub enabled: bool,
+    /// When `true`, every successful RPC also emits an `rpc.ok`
+    /// audit event. Off by default — successful RPCs are already
+    /// counted in `scg_rpcs_total{code="OK"}` and emitting one
+    /// audit event per successful RPC drowns out the events
+    /// operators actually care about. Switch on under strict
+    /// monitoring.
+    #[serde(default)]
+    pub log_successful_rpcs: bool,
+}
+
+fn default_audit_enabled() -> bool {
+    true
+}
+
+impl Default for AuditSettings {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            log_successful_rpcs: false,
+        }
+    }
+}
+
+/// Per-tenant rate-limit configuration. Token bucket
+/// per tenant with an optional per-user sub-bucket; both are
+/// disabled (`rpcs_per_second: 0`) by default so the limiter is a
+/// no-op until the operator opts in.
+///
+/// Tenants not listed in `overrides` use `default`; an inbound RPC
+/// is admitted only when *both* applicable buckets (tenant +
+/// per-user, if enabled) have tokens.
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct RateLimitSettings {
+    /// Master switch. When `false` (default), no rate limiting is
+    /// performed even if `default` / `overrides` are populated.
+    #[serde(default)]
+    pub enabled: bool,
+    /// Backend store for the bucket state. `memory` (default)
+    /// enforces quotas per gateway replica; `redis` shares state
+    /// across all replicas via a Lua-driven token bucket. See
+    /// `crates/ratelimit/src/redis.rs` for the wire format and the
+    /// trade-off between the two stores.
+    #[serde(default)]
+    pub store: RateLimitStore,
+    /// Redis connection settings — only consulted when `store: redis`.
+    #[serde(default)]
+    pub redis: RateLimitRedisSettings,
+    #[serde(default)]
+    pub default: BucketSettings,
+    #[serde(default)]
+    pub overrides: std::collections::HashMap<String, BucketSettings>,
+}
+
+/// Which backend stores the token-bucket state.
+#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum RateLimitStore {
+    /// Each gateway replica enforces its own bucket. Fine for
+    /// single-replica deployments or back-pressure-style limiting.
+    #[default]
+    Memory,
+    /// Atomic token bucket in Redis, shared across all replicas.
+    /// The effective cluster-wide quota matches the configured
+    /// rates exactly.
+    Redis,
+}
+
+/// Redis settings for the distributed limiter. Defaults are
+/// dev-friendly — production should override `url`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct RateLimitRedisSettings {
+    /// Redis URL. e.g. `redis://redis.spark-connect.svc:6379`.
+    #[serde(default = "default_rate_limit_redis_url")]
+    pub url: String,
+    /// Key prefix. All limiter keys live under `{key_prefix}:t:*`
+    /// (tenant) and `{key_prefix}:u:*` (user). Default `scg-rl` —
+    /// distinct from the affinity store's prefix so flushing one
+    /// doesn't disturb the other.
+    #[serde(default = "default_rate_limit_redis_key_prefix")]
+    pub key_prefix: String,
+    /// TTL for an idle bucket key in seconds. Default 3600 (one
+    /// hour) — long enough that an idle tenant doesn't lose its
+    /// bucket, short enough that one-off keys GC themselves.
+    #[serde(default = "default_rate_limit_redis_key_ttl_secs")]
+    pub key_ttl_secs: u64,
+    /// Behaviour when Redis is unreachable. `open` (default) admits
+    /// the RPC and bumps `scg_rate_limit_redis_errors_total`;
+    /// `closed` rejects the RPC with `ResourceExhausted`.
+    #[serde(default)]
+    pub on_failure: RateLimitFailMode,
+}
+
+impl Default for RateLimitRedisSettings {
+    fn default() -> Self {
+        Self {
+            url: default_rate_limit_redis_url(),
+            key_prefix: default_rate_limit_redis_key_prefix(),
+            key_ttl_secs: default_rate_limit_redis_key_ttl_secs(),
+            on_failure: RateLimitFailMode::default(),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum RateLimitFailMode {
+    /// Admit the RPC; bump the error metric. Recommended default —
+    /// availability over strict quotas.
+    #[default]
+    Open,
+    /// Reject the RPC with `ResourceExhausted`. Use for strict-SaaS
+    /// deployments where a Redis outage must not become a
+    /// quota-bypass vector; makes Redis a hard request-path
+    /// dependency.
+    Closed,
+}
+
+fn default_rate_limit_redis_url() -> String {
+    "redis://localhost:6379".into()
+}
+fn default_rate_limit_redis_key_prefix() -> String {
+    "scg-rl".into()
+}
+fn default_rate_limit_redis_key_ttl_secs() -> u64 {
+    3600
+}
+
+#[derive(Debug, Clone, Copy, Default, Deserialize)]
+pub struct BucketSettings {
+    /// Per-tenant token-bucket refill rate (RPCs/second). `0`
+    /// disables the per-tenant bucket entirely.
+    #[serde(default)]
+    pub rpcs_per_second: f64,
+    /// Per-tenant token-bucket capacity (max consecutive RPCs
+    /// before the limiter kicks in).
+    #[serde(default)]
+    pub burst: u64,
+    /// Per-user token-bucket refill rate inside the tenant. `0`
+    /// (default) disables this dimension — only the per-tenant
+    /// bucket is consulted.
+    #[serde(default)]
+    pub per_user_rpcs_per_second: f64,
+    #[serde(default)]
+    pub per_user_burst: u64,
+}
+
+/// Per-tenant backend pool overrides. A multi-tenant deployment
+/// lists one entry per tenant that needs its own pool;
+/// any tenant *not* listed here routes through the deployment's
+/// default pool (the existing `backends:` / `backend_discovery:`
+/// settings).
+///
+/// The fallback `policy` decides what happens when an inbound RPC
+/// carries a tenant that has neither an explicit override nor (in
+/// the `Reject` case) any pool at all. Default `UseDefault` matches
+/// the single-tenant baseline — everything routes to the default
+/// pool. `Reject` is the right choice for SaaS-style deployments
+/// where unconfigured tenants must not get any access.
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct TenantPoolsSettings {
+    /// Tenant name → its own pool's discovery configuration. The
+    /// `default` tenant is **not** special here; if you want the
+    /// default pool to be different from what `backends:` /
+    /// `backend_discovery:` provides, list it as an override too.
+    #[serde(default)]
+    pub overrides: std::collections::HashMap<String, TenantPoolConfig>,
+    /// What to do when an inbound RPC has a tenant that's not in
+    /// `overrides`. `use_default` (default) routes through the
+    /// deployment's default pool; `reject` returns
+    /// `PermissionDenied` to the client.
+    #[serde(default = "default_unknown_tenant_policy")]
+    pub on_unknown_tenant: UnknownTenantPolicySetting,
+}
+
+/// One tenant's pool configuration: a discovery source plus,
+/// optionally, its own backend token. The discovery fields sit at
+/// the same YAML level as `backend_token` (serde-flattened), so
+/// existing override entries keep parsing unchanged:
+///
+/// ```yaml
+/// tenant_pools:
+///   overrides:
+///     team-a:
+///       type: static
+///       addresses: ["a-1:15002"]
+///       backend_token:          # optional
+///         kind: env
+///         name: TEAM_A_BACKEND_TOKEN
+/// ```
+///
+/// A tenant override *without* `backend_token` inherits the
+/// top-level `backend_token` (if any).
+#[derive(Debug, Clone, Deserialize)]
+pub struct TenantPoolConfig {
+    #[serde(flatten)]
+    pub discovery: BackendDiscovery,
+    #[serde(default)]
+    pub backend_token: Option<BackendTokenSource>,
+}
+
+#[derive(Debug, Clone, Copy, Default, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum UnknownTenantPolicySetting {
+    #[default]
+    UseDefault,
+    Reject,
+}
+
+fn default_unknown_tenant_policy() -> UnknownTenantPolicySetting {
+    UnknownTenantPolicySetting::UseDefault
+}
+
+/// How the gateway figures out which tenant an inbound RPC belongs
+/// to. The resolved tenant becomes the first segment of the routing
+/// key, so two tenants with the same `session_id` get isolated
+/// affinity buckets.
+///
+/// Deployments without a `tenant_resolver:` block fall back to
+/// `from_claim + use_default + "default"`, so every RPC ends up
+/// in `tenant="default"` — the single-tenant baseline.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "source", rename_all = "snake_case")]
+pub enum TenantResolverSource {
+    /// Read from `Identity.tenant` produced by the auth interceptor
+    /// (JWT/OIDC `tenant` claim, static-token `tenant` field).
+    FromClaim,
+    /// Read from a gRPC metadata header. For deployments where auth
+    /// is disabled but clients still cooperate by declaring a
+    /// tenant.
+    FromMetadata {
+        #[serde(default = "default_tenant_header")]
+        header: String,
+    },
+    /// Always use `default_name`. Single-tenant deployments that
+    /// don't want to bother with auth claims or headers.
+    AlwaysDefault,
+}
+
+fn default_tenant_header() -> String {
+    "x-tenant".into()
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TenantOnMissing {
+    /// Fall back to `default_name` when the source yields nothing.
+    /// The default — preserves single-tenant behaviour for
+    /// deployments that haven't opted into multi-tenant routing.
+    UseDefault,
+    /// Fail the RPC with `Unauthenticated`. Used by SaaS-style
+    /// deployments where a missing tenant claim almost always means
+    /// the IdP is misconfigured.
+    Reject,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct TenantResolverSettings {
+    #[serde(flatten)]
+    pub source: TenantResolverSource,
+    #[serde(default = "default_tenant_on_missing")]
+    pub on_missing: TenantOnMissing,
+    #[serde(default = "default_tenant_name")]
+    pub default_name: String,
+}
+
+fn default_tenant_on_missing() -> TenantOnMissing {
+    TenantOnMissing::UseDefault
+}
+fn default_tenant_name() -> String {
+    "default".into()
+}
+
+impl Default for TenantResolverSettings {
+    fn default() -> Self {
+        Self {
+            source: TenantResolverSource::FromClaim,
+            on_missing: TenantOnMissing::UseDefault,
+            default_name: default_tenant_name(),
+        }
+    }
+}
+
+/// Active gRPC health-check probing for backend pool members. Wraps
+/// the configured pool with a probe loop that calls
+/// `grpc.health.v1.Health/Check` on each backend and removes
+/// repeatedly-failing ones from `pick()`. Off by default to avoid
+/// breaking deployments where backends don't ship the standard
+/// Health service.
+#[derive(Debug, Clone, Deserialize)]
+pub struct HealthCheckSettings {
+    /// Master switch. `false` (default) skips active probing —
+    /// routing fails through to the next session on a forward
+    /// error instead.
+    #[serde(default)]
+    pub enabled: bool,
+    #[serde(default = "default_hc_interval_secs")]
+    pub interval_secs: u64,
+    #[serde(default = "default_hc_timeout_secs")]
+    pub timeout_secs: u64,
+    #[serde(default = "default_hc_unhealthy_threshold")]
+    pub unhealthy_threshold: u32,
+    #[serde(default = "default_hc_healthy_threshold")]
+    pub healthy_threshold: u32,
+}
+
+impl Default for HealthCheckSettings {
+    fn default() -> Self {
+        Self {
+            enabled: false,
+            interval_secs: default_hc_interval_secs(),
+            timeout_secs: default_hc_timeout_secs(),
+            unhealthy_threshold: default_hc_unhealthy_threshold(),
+            healthy_threshold: default_hc_healthy_threshold(),
+        }
+    }
+}
+
+fn default_hc_interval_secs() -> u64 {
+    5
+}
+fn default_hc_timeout_secs() -> u64 {
+    2
+}
+fn default_hc_unhealthy_threshold() -> u32 {
+    3
+}
+fn default_hc_healthy_threshold() -> u32 {
+    2
+}
+
+/// Graceful shutdown behaviour. On SIGINT/SIGTERM, the gateway
+/// flips `/readyz` to 503 (so K8s drains it from the Service), then
+/// waits for in-flight streaming RPCs (`ExecutePlan`,
+/// `ReattachExecute`, `AddArtifacts`) to complete, up to
+/// `deadline_secs`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct ShutdownSettings {
+    /// Hard ceiling on the drain period. After this many seconds the
+    /// gateway forcibly shuts down regardless of in-flight streams.
+    /// Pick something compatible with your K8s
+    /// `terminationGracePeriodSeconds` (the chart defaults to 30).
+    #[serde(default = "default_shutdown_deadline_secs")]
+    pub deadline_secs: u64,
+}
+
+impl Default for ShutdownSettings {
+    fn default() -> Self {
+        Self {
+            deadline_secs: default_shutdown_deadline_secs(),
+        }
+    }
+}
+
+fn default_shutdown_deadline_secs() -> u64 {
+    30
+}
+
+/// Distributed-tracing configuration. Off by default — configs
+/// without a `tracing:` section keep working.
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct TracingSettings {
+    /// OTLP/gRPC collector endpoint (e.g. `http://otel-collector:4317`).
+    /// `None` disables span export — only the JSON log formatter runs.
+    #[serde(default)]
+    pub endpoint: Option<String>,
+    /// `service.name` resource attribute reported on every span.
+    #[serde(default = "default_service_name")]
+    pub service_name: String,
+    /// `service.version` resource attribute. Defaults to the
+    /// gateway's compile-time CARGO_PKG_VERSION when omitted.
+    #[serde(default)]
+    pub service_version: Option<String>,
+    /// `TraceIdRatioBased` sampling ratio in `[0.0, 1.0]`. Wrapped in
+    /// `ParentBased` at runtime so a sampled remote parent always wins.
+    #[serde(default = "default_sample_ratio")]
+    pub sample_ratio: f64,
+    /// Per-batch OTLP export deadline, in seconds.
+    #[serde(default = "default_export_timeout_secs")]
+    pub export_timeout_secs: u64,
+}
+
+fn default_service_name() -> String {
+    "spark-connect-gateway".into()
+}
+fn default_sample_ratio() -> f64 {
+    1.0
+}
+fn default_export_timeout_secs() -> u64 {
+    10
+}
+
+/// Raw YAML shape — accepts either the legacy `backends` shorthand or the
+/// tagged `backend_discovery` form, never both.
+#[derive(Debug, Deserialize)]
+struct RawConfig {
+    #[serde(default = "default_bind_addr")]
+    bind_addr: String,
+    #[serde(default)]
+    backends: Option<Vec<String>>,
+    #[serde(default)]
+    backend_discovery: Option<BackendDiscovery>,
+    #[serde(default)]
+    backend_token: Option<BackendTokenSource>,
+    #[serde(default)]
+    auth: Option<AuthConfig>,
+    /// Address for the admin / metrics HTTP server. `null` disables it.
+    /// Default `0.0.0.0:9090`.
+    #[serde(default = "default_admin_addr_opt")]
+    admin_addr: Option<String>,
+    #[serde(default)]
+    tracing: Option<TracingSettings>,
+    #[serde(default)]
+    affinity_store: Option<AffinityStoreConfig>,
+    #[serde(default)]
+    health_check: Option<HealthCheckSettings>,
+    #[serde(default)]
+    shutdown: Option<ShutdownSettings>,
+    #[serde(default)]
+    tenant_resolver: Option<TenantResolverSettings>,
+    #[serde(default)]
+    tenant_pools: Option<TenantPoolsSettings>,
+    #[serde(default)]
+    rate_limit: Option<RateLimitSettings>,
+    #[serde(default)]
+    audit: Option<AuditSettings>,
+}
+
+fn default_admin_addr_opt() -> Option<String> {
+    Some(":9090".into())
+}
+
+#[derive(Debug, Clone)]
+pub struct Config {
+    pub bind_addr: String,
+    /// The default pool's discovery source. `None` is permitted only
+    /// under `tenant_pools.on_unknown_tenant: reject` with at least
+    /// one tenant override — in that mode the default pool is never
+    /// selected (unmatched tenants are rejected before pool
+    /// selection), so requiring a discovery source would force
+    /// operators to configure a pool that cannot receive traffic.
+    pub discovery: Option<BackendDiscovery>,
+    /// Pre-shared token the gateway presents (as `authorization:
+    /// Bearer <token>`) on every gateway→backend request. Applies to
+    /// all pools unless a tenant override carries its own
+    /// `backend_token`. `None` sends no credential — only safe when
+    /// the backends don't enforce `spark.connect.authenticate.token`.
+    pub backend_token: Option<BackendTokenSource>,
+    pub auth: AuthConfig,
+    /// `Some(addr)` to enable the admin HTTP server, `None` to skip it.
+    pub admin_addr: Option<String>,
+    /// Distributed-tracing settings. `None` keeps tracing off (the
+    /// gateway only emits structured JSON logs in that case).
+    pub tracing: Option<TracingSettings>,
+    /// Where to keep affinity state. Defaults to in-memory; use
+    /// `redis` for multi-replica HA.
+    pub affinity_store: AffinityStoreConfig,
+    /// Active gRPC health-check probing for backends. Off by default.
+    pub health_check: HealthCheckSettings,
+    /// Graceful shutdown / drain settings.
+    pub shutdown: ShutdownSettings,
+    /// How to figure out the tenant for each inbound RPC. Defaults
+    /// to the back-compat behaviour (every RPC -> tenant="default").
+    pub tenant_resolver: TenantResolverSettings,
+    /// Per-tenant backend pool overrides + unknown-tenant policy.
+    /// Empty `overrides` + `use_default` reproduces the single-pool
+    /// baseline.
+    pub tenant_pools: TenantPoolsSettings,
+    /// Per-tenant rate limiting. Disabled by default.
+    pub rate_limit: RateLimitSettings,
+    /// Structured audit logging. Enabled by default.
+    pub audit: AuditSettings,
+}
+
+fn default_bind_addr() -> String {
+    ":15003".into()
+}
+
+impl Config {
+    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
+        let path_str = path.as_ref().display().to_string();
+        let data = std::fs::read_to_string(path.as_ref()).map_err(|e| 
ConfigError::Io {
+            path: path_str.clone(),
+            source: e,
+        })?;
+        let raw: RawConfig = serde_yaml::from_str(&data).map_err(|e| 
ConfigError::Parse {
+            path: path_str,
+            source: e,
+        })?;
+        Self::from_raw(raw)
+    }
+
+    fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
+        let tenant_pools = raw.tenant_pools.unwrap_or_default();
+        let discovery = match (raw.backends, raw.backend_discovery) {
+            (Some(_), Some(_)) => return 
Err(ConfigError::ConflictingDiscovery),
+            (None, None) => {
+                // No default pool is acceptable only when routing can
+                // never select it: strict multi-tenant deployments
+                // where every admitted tenant has its own pool and
+                // everything else is rejected.
+                let strict = matches!(
+                    tenant_pools.on_unknown_tenant,
+                    UnknownTenantPolicySetting::Reject
+                ) && !tenant_pools.overrides.is_empty();
+                if !strict {
+                    return Err(ConfigError::NoDiscoverySource);
+                }
+                None
+            }
+            (Some(addrs), None) => {
+                if addrs.is_empty() {
+                    return Err(ConfigError::EmptyStatic);
+                }
+                Some(BackendDiscovery::Static { addresses: addrs })
+            }
+            (None, Some(d)) => {
+                if let BackendDiscovery::Static { addresses } = &d {
+                    if addresses.is_empty() {
+                        return Err(ConfigError::EmptyStatic);
+                    }
+                }
+                Some(d)
+            }
+        };
+        let bind_addr = if raw.bind_addr.is_empty() {
+            default_bind_addr()
+        } else {
+            raw.bind_addr
+        };
+        Ok(Self {
+            bind_addr,
+            discovery,
+            backend_token: raw.backend_token,
+            auth: raw.auth.unwrap_or_default(),
+            admin_addr: raw.admin_addr.filter(|s| !s.is_empty()),
+            tracing: raw.tracing,
+            affinity_store: raw.affinity_store.unwrap_or_default(),
+            health_check: raw.health_check.unwrap_or_default(),
+            shutdown: raw.shutdown.unwrap_or_default(),
+            tenant_resolver: raw.tenant_resolver.unwrap_or_default(),
+            tenant_pools,
+            rate_limit: raw.rate_limit.unwrap_or_default(),
+            audit: raw.audit.unwrap_or_default(),
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::Write;
+
+    fn write(text: &str) -> tempfile::NamedTempFile {
+        let mut f = tempfile::NamedTempFile::new().unwrap();
+        write!(f, "{}", text).unwrap();
+        f
+    }
+
+    #[test]
+    fn loads_legacy_backends_shorthand() {
+        let f = write(
+            r#"
+bind_addr: ":15003"
+backends:
+  - "127.0.0.1:15002"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.bind_addr, ":15003");
+        match c.discovery {
+            Some(BackendDiscovery::Static { addresses }) => {
+                assert_eq!(addresses, vec!["127.0.0.1:15002"]);
+            }
+            other => panic!("expected Static, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn loads_tagged_static() {
+        let f = write(
+            r#"
+backend_discovery:
+  type: static
+  addresses: ["a:1", "b:2"]
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.discovery {
+            Some(BackendDiscovery::Static { addresses }) => {
+                assert_eq!(addresses, vec!["a:1", "b:2"]);
+            }
+            other => panic!("expected Static, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn loads_tagged_k8s() {
+        let f = write(
+            r#"
+backend_discovery:
+  type: k8s
+  namespace: spark-connect
+  service_name: spark-connect
+  port: 15002
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.discovery {
+            Some(BackendDiscovery::K8s {
+                namespace,
+                service_name,
+                port,
+            }) => {
+                assert_eq!(namespace, "spark-connect");
+                assert_eq!(service_name, "spark-connect");
+                assert_eq!(port, 15002);
+            }
+            other => panic!("expected K8s, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn omitted_discovery_allowed_under_reject_with_overrides() {
+        // Strict multi-tenant mode: every admitted tenant has its own
+        // pool, unknown tenants are rejected — the default pool would
+        // never be selected, so requiring one would be dead config.
+        let f = write(
+            r#"
+tenant_pools:
+  on_unknown_tenant: reject
+  overrides:
+    team-a:
+      type: static
+      addresses: ["a:15002"]
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.discovery.is_none());
+        assert!(matches!(
+            c.tenant_pools.on_unknown_tenant,
+            UnknownTenantPolicySetting::Reject
+        ));
+        assert_eq!(c.tenant_pools.overrides.len(), 1);
+    }
+
+    #[test]
+    fn omitted_discovery_rejected_under_use_default() {
+        // With use_default (explicit or implicit), the default pool
+        // is reachable, so a discovery source is mandatory.
+        let f = write(
+            r#"
+tenant_pools:
+  on_unknown_tenant: use_default
+  overrides:
+    team-a:
+      type: static
+      addresses: ["a:15002"]
+"#,
+        );
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::NoDiscoverySource
+        ));
+    }
+
+    #[test]
+    fn omitted_discovery_rejected_without_overrides() {
+        // reject + zero overrides would reject every RPC — that's a
+        // config mistake, not a deployment shape; fail loudly.
+        let f = write(
+            r#"
+tenant_pools:
+  on_unknown_tenant: reject
+"#,
+        );
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::NoDiscoverySource
+        ));
+    }
+
+    #[test]
+    fn empty_backends_shorthand_rejected() {
+        let f = write("backends: []\n");
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::EmptyStatic
+        ));
+    }
+
+    #[test]
+    fn empty_static_in_tagged_form_rejected() {
+        let f = write(
+            r#"
+backend_discovery:
+  type: static
+  addresses: []
+"#,
+        );
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::EmptyStatic
+        ));
+    }
+
+    #[test]
+    fn missing_discovery_rejected() {
+        let f = write("bind_addr: ':15003'\n");
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::NoDiscoverySource
+        ));
+    }
+
+    #[test]
+    fn conflicting_discovery_rejected() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+backend_discovery:
+  type: static
+  addresses: ["b:2"]
+"#,
+        );
+        assert!(matches!(
+            Config::load(f.path()).unwrap_err(),
+            ConfigError::ConflictingDiscovery
+        ));
+    }
+
+    #[test]
+    fn defaults_bind_addr() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.bind_addr, ":15003");
+    }
+
+    #[test]
+    fn auth_defaults_to_none() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(matches!(c.auth, AuthConfig::None));
+    }
+
+    #[test]
+    fn loads_static_auth() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+auth:
+  type: static
+  tokens:
+    - token: "alice-secret"
+      user_id: "alice"
+      tenant: "team-a"
+      groups: ["devs"]
+    - token: "bob-secret"
+      user_id: "bob"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.auth {
+            AuthConfig::Static { tokens } => {
+                assert_eq!(tokens.len(), 2);
+                assert_eq!(tokens[0].user_id, "alice");
+                assert_eq!(tokens[0].tenant.as_deref(), Some("team-a"));
+                assert_eq!(tokens[1].user_id, "bob");
+                assert!(tokens[1].tenant.is_none());
+            }
+            other => panic!("expected Static, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn loads_jwt_auth() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+auth:
+  type: jwt
+  algorithms: ["RS256"]
+  issuer: "https://idp.example.com";
+  audience: "spark-connect-gateway"
+  key:
+    kind: pem_file
+    path: "/etc/gateway/idp-pub.pem"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.auth {
+            AuthConfig::Jwt(s) => {
+                assert_eq!(s.algorithms, vec!["RS256"]);
+                assert_eq!(s.issuer.as_deref(), 
Some("https://idp.example.com";));
+                match s.key {
+                    KeySource::PemFile { path } => assert_eq!(path, 
"/etc/gateway/idp-pub.pem"),
+                    other => panic!("expected PemFile, got {:?}", other),
+                }
+            }
+            other => panic!("expected Jwt, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn tracing_defaults_to_off() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.tracing.is_none());
+    }
+
+    #[test]
+    fn loads_tracing_block() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+tracing:
+  endpoint: "http://otel-collector:4317";
+  service_name: "scg-staging"
+  sample_ratio: 0.25
+  export_timeout_secs: 5
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        let t = c.tracing.expect("tracing settings parsed");
+        assert_eq!(t.endpoint.as_deref(), Some("http://otel-collector:4317";));
+        assert_eq!(t.service_name, "scg-staging");
+        assert!((t.sample_ratio - 0.25).abs() < 1e-9);
+        assert_eq!(t.export_timeout_secs, 5);
+    }
+
+    #[test]
+    fn tracing_block_endpoint_can_be_omitted_for_log_only() {
+        // A `tracing:` block without an endpoint is legal — the gateway
+        // skips OTLP export but still respects the other knobs (e.g.
+        // service_name) for when the user later sets an endpoint.
+        let f = write(
+            r#"
+backends: ["a:1"]
+tracing:
+  service_name: "scg-test"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        let t = c.tracing.expect("tracing settings parsed");
+        assert!(t.endpoint.is_none());
+        assert_eq!(t.service_name, "scg-test");
+        // Defaults round-trip:
+        assert!((t.sample_ratio - 1.0).abs() < 1e-9);
+        assert_eq!(t.export_timeout_secs, 10);
+    }
+
+    #[test]
+    fn affinity_store_defaults_to_memory() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(matches!(c.affinity_store, AffinityStoreConfig::Memory));
+    }
+
+    #[test]
+    fn loads_redis_affinity_store() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+affinity_store:
+  type: redis
+  url: "redis://redis-cluster:6379"
+  key_prefix: "scg-prod"
+  session_ttl_secs: 7200
+  op_ttl_secs: 600
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.affinity_store {
+            AffinityStoreConfig::Redis(s) => {
+                assert_eq!(s.url, "redis://redis-cluster:6379");
+                assert_eq!(s.key_prefix, "scg-prod");
+                assert_eq!(s.session_ttl_secs, 7200);
+                assert_eq!(s.op_ttl_secs, 600);
+            }
+            other => panic!("expected Redis, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn redis_affinity_store_has_sane_defaults() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+affinity_store:
+  type: redis
+  url: "redis://localhost:6379"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.affinity_store {
+            AffinityStoreConfig::Redis(s) => {
+                assert_eq!(s.key_prefix, "scg");
+                assert_eq!(s.session_ttl_secs, 3600);
+                assert_eq!(s.op_ttl_secs, 900);
+            }
+            other => panic!("expected Redis, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn health_check_defaults_to_disabled() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(!c.health_check.enabled);
+        // Default values present even when block omitted:
+        assert_eq!(c.health_check.interval_secs, 5);
+        assert_eq!(c.health_check.unhealthy_threshold, 3);
+    }
+
+    #[test]
+    fn loads_health_check_block() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+health_check:
+  enabled: true
+  interval_secs: 10
+  timeout_secs: 3
+  unhealthy_threshold: 5
+  healthy_threshold: 3
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.health_check.enabled);
+        assert_eq!(c.health_check.interval_secs, 10);
+        assert_eq!(c.health_check.timeout_secs, 3);
+        assert_eq!(c.health_check.unhealthy_threshold, 5);
+        assert_eq!(c.health_check.healthy_threshold, 3);
+    }
+
+    #[test]
+    fn shutdown_defaults() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.shutdown.deadline_secs, 30);
+    }
+
+    #[test]
+    fn loads_shutdown_block() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+shutdown:
+  deadline_secs: 90
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.shutdown.deadline_secs, 90);
+    }
+
+    #[test]
+    fn tenant_resolver_defaults_to_from_claim_use_default() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(matches!(
+            c.tenant_resolver.source,
+            TenantResolverSource::FromClaim
+        ));
+        assert!(matches!(
+            c.tenant_resolver.on_missing,
+            TenantOnMissing::UseDefault
+        ));
+        assert_eq!(c.tenant_resolver.default_name, "default");
+    }
+
+    #[test]
+    fn loads_from_claim_reject() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+tenant_resolver:
+  source: from_claim
+  on_missing: reject
+  default_name: "default"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(matches!(
+            c.tenant_resolver.source,
+            TenantResolverSource::FromClaim
+        ));
+        assert!(matches!(
+            c.tenant_resolver.on_missing,
+            TenantOnMissing::Reject
+        ));
+    }
+
+    #[test]
+    fn loads_from_metadata_with_custom_header() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+tenant_resolver:
+  source: from_metadata
+  header: "x-org"
+  on_missing: use_default
+  default_name: "shared"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.tenant_resolver.source {
+            TenantResolverSource::FromMetadata { header } => 
assert_eq!(header, "x-org"),
+            other => panic!("expected FromMetadata, got {:?}", other),
+        }
+        assert_eq!(c.tenant_resolver.default_name, "shared");
+    }
+
+    #[test]
+    fn from_metadata_header_defaults_to_x_tenant() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+tenant_resolver:
+  source: from_metadata
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.tenant_resolver.source {
+            TenantResolverSource::FromMetadata { header } => 
assert_eq!(header, "x-tenant"),
+            other => panic!("expected FromMetadata, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn loads_always_default() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+tenant_resolver:
+  source: always_default
+  default_name: "single-tenant"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(matches!(
+            c.tenant_resolver.source,
+            TenantResolverSource::AlwaysDefault
+        ));
+        assert_eq!(c.tenant_resolver.default_name, "single-tenant");
+    }
+
+    #[test]
+    fn tenant_pools_default_empty_use_default() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.tenant_pools.overrides.is_empty());
+        assert!(matches!(
+            c.tenant_pools.on_unknown_tenant,
+            UnknownTenantPolicySetting::UseDefault
+        ));
+    }
+
+    #[test]
+    fn loads_tenant_pools_with_overrides() {
+        let f = write(
+            r#"
+backends: ["default-a:1", "default-b:1"]
+tenant_pools:
+  on_unknown_tenant: reject
+  overrides:
+    team-a:
+      type: static
+      addresses: ["a-1:15002", "a-2:15002"]
+    team-b:
+      type: k8s
+      namespace: spark-b
+      service_name: spark-connect
+      port: 15002
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.tenant_pools.overrides.len(), 2);
+        assert!(matches!(
+            c.tenant_pools.on_unknown_tenant,
+            UnknownTenantPolicySetting::Reject
+        ));
+        let team_a = c.tenant_pools.overrides.get("team-a").unwrap();
+        assert!(team_a.backend_token.is_none());
+        match &team_a.discovery {
+            BackendDiscovery::Static { addresses } => {
+                assert_eq!(
+                    addresses,
+                    &vec!["a-1:15002".to_string(), "a-2:15002".to_string()]
+                )
+            }
+            other => panic!("expected Static, got {:?}", other),
+        }
+        match &c.tenant_pools.overrides.get("team-b").unwrap().discovery {
+            BackendDiscovery::K8s {
+                namespace,
+                service_name,
+                port,
+            } => {
+                assert_eq!(namespace, "spark-b");
+                assert_eq!(service_name, "spark-connect");
+                assert_eq!(*port, 15002);
+            }
+            other => panic!("expected K8s, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn backend_token_defaults_to_none() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.backend_token.is_none());
+    }
+
+    #[test]
+    fn loads_inline_backend_token() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+backend_token:
+  kind: inline
+  token: "deadbeef"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        let src = c.backend_token.expect("backend_token parsed");
+        assert_eq!(src.resolve().unwrap(), "deadbeef");
+    }
+
+    #[test]
+    fn loads_env_backend_token() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+backend_token:
+  kind: env
+  name: SCG_TEST_BACKEND_TOKEN
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        let src = c.backend_token.expect("backend_token parsed");
+        assert!(matches!(
+            src.resolve().unwrap_err(),
+            ConfigError::TokenEnvMissing { .. }
+        ));
+        // Env-var manipulation is process-global; this test relies on
+        // the name being unique to it, so set/remove is safe even
+        // with the parallel test runner.
+        std::env::set_var("SCG_TEST_BACKEND_TOKEN", "from-env");
+        assert_eq!(src.resolve().unwrap(), "from-env");
+        std::env::remove_var("SCG_TEST_BACKEND_TOKEN");
+    }
+
+    #[test]
+    fn loads_file_backend_token_and_trims_newline() {
+        let tok = write("s3cr3t\n");
+        let f = write(&format!(
+            "backends: [\"a:1\"]\nbackend_token:\n  kind: file\n  path: 
\"{}\"\n",
+            tok.path().display()
+        ));
+        let c = Config::load(f.path()).unwrap();
+        let src = c.backend_token.expect("backend_token parsed");
+        assert_eq!(src.resolve().unwrap(), "s3cr3t");
+    }
+
+    #[test]
+    fn empty_backend_token_rejected_at_resolve() {
+        let src = BackendTokenSource::Inline { token: "".into() };
+        assert!(matches!(
+            src.resolve().unwrap_err(),
+            ConfigError::TokenEmpty
+        ));
+    }
+
+    #[test]
+    fn tenant_override_can_carry_its_own_backend_token() {
+        // `backend_token` sits at the same level as the flattened
+        // discovery fields inside an override entry.
+        let f = write(
+            r#"
+backends: ["default:15002"]
+backend_token:
+  kind: inline
+  token: "default-token"
+tenant_pools:
+  overrides:
+    team-a:
+      type: static
+      addresses: ["a-1:15002"]
+      backend_token:
+        kind: inline
+        token: "team-a-token"
+    team-b:
+      type: static
+      addresses: ["b-1:15002"]
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        let a = c.tenant_pools.overrides.get("team-a").unwrap();
+        assert_eq!(
+            a.backend_token.as_ref().unwrap().resolve().unwrap(),
+            "team-a-token"
+        );
+        // team-b has no token of its own — inheritance of the
+        // top-level token happens at wiring time, not parse time.
+        let b = c.tenant_pools.overrides.get("team-b").unwrap();
+        assert!(b.backend_token.is_none());
+        assert!(matches!(
+            &b.discovery,
+            BackendDiscovery::Static { addresses } if addresses == 
&vec!["b-1:15002".to_string()]
+        ));
+    }
+
+    #[test]
+    fn rate_limit_defaults_to_disabled() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(!c.rate_limit.enabled);
+        assert_eq!(c.rate_limit.default.rpcs_per_second, 0.0);
+        assert!(c.rate_limit.overrides.is_empty());
+    }
+
+    #[test]
+    fn loads_rate_limit_with_overrides() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+rate_limit:
+  enabled: true
+  default:
+    rpcs_per_second: 100
+    burst: 200
+  overrides:
+    team-a:
+      rpcs_per_second: 500
+      burst: 1000
+      per_user_rpcs_per_second: 50
+      per_user_burst: 100
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.rate_limit.enabled);
+        assert_eq!(c.rate_limit.default.rpcs_per_second, 100.0);
+        assert_eq!(c.rate_limit.default.burst, 200);
+        let a = c.rate_limit.overrides.get("team-a").unwrap();
+        assert_eq!(a.rpcs_per_second, 500.0);
+        assert_eq!(a.burst, 1000);
+        assert_eq!(a.per_user_rpcs_per_second, 50.0);
+        assert_eq!(a.per_user_burst, 100);
+    }
+
+    #[test]
+    fn rate_limit_store_defaults_to_memory() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+rate_limit:
+  enabled: true
+  default:
+    rpcs_per_second: 100
+    burst: 200
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.rate_limit.store, RateLimitStore::Memory);
+        // Redis defaults are present but irrelevant when store=memory.
+        assert_eq!(c.rate_limit.redis.url, "redis://localhost:6379");
+        assert_eq!(c.rate_limit.redis.key_prefix, "scg-rl");
+        assert_eq!(c.rate_limit.redis.on_failure, RateLimitFailMode::Open);
+    }
+
+    #[test]
+    fn loads_rate_limit_with_redis_store() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+rate_limit:
+  enabled: true
+  store: redis
+  redis:
+    url: "redis://shared.svc:6379"
+    key_prefix: "myrl"
+    key_ttl_secs: 7200
+    on_failure: closed
+  default:
+    rpcs_per_second: 100
+    burst: 200
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert_eq!(c.rate_limit.store, RateLimitStore::Redis);
+        assert_eq!(c.rate_limit.redis.url, "redis://shared.svc:6379");
+        assert_eq!(c.rate_limit.redis.key_prefix, "myrl");
+        assert_eq!(c.rate_limit.redis.key_ttl_secs, 7200);
+        assert_eq!(c.rate_limit.redis.on_failure, RateLimitFailMode::Closed);
+    }
+
+    #[test]
+    fn audit_defaults_to_enabled_signals_only() {
+        let f = write("backends: [\"a:1\"]\n");
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.audit.enabled);
+        assert!(!c.audit.log_successful_rpcs);
+    }
+
+    #[test]
+    fn loads_audit_block_with_successful_rpcs_on() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+audit:
+  enabled: true
+  log_successful_rpcs: true
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(c.audit.enabled);
+        assert!(c.audit.log_successful_rpcs);
+    }
+
+    #[test]
+    fn audit_can_be_disabled() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+audit:
+  enabled: false
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        assert!(!c.audit.enabled);
+    }
+
+    #[test]
+    fn loads_oidc_auth() {
+        let f = write(
+            r#"
+backends: ["a:1"]
+auth:
+  type: oidc
+  algorithms: ["RS256"]
+  discovery_url: "https://idp.example.com/.well-known/openid-configuration";
+  audience: "spark-connect-gateway"
+"#,
+        );
+        let c = Config::load(f.path()).unwrap();
+        match c.auth {
+            AuthConfig::Oidc(s) => {
+                assert_eq!(
+                    s.discovery_url.as_deref(),
+                    
Some("https://idp.example.com/.well-known/openid-configuration";)
+                );
+                assert!(s.jwks_url.is_none());
+            }
+            other => panic!("expected Oidc, got {:?}", other),
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to