Skip to main content

lodestone_client/
transport.rs

1use std::collections::VecDeque;
2use std::error::Error;
3use std::fmt::{self, Display, Formatter};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use lodestone_proto::{MAX_PUBLIC_SESSION_BYTES, PUBLIC_SESSION_FORMAT_VERSION};
9use lodestone_wire::{
10    Action, Command, CoreBinding, DiscoverRequest, DiscoverResult, ErrorBody, ErrorDisposition, GetBindingRequest,
11    GetBindingResult, Identity, LODESTONE_API_ALPN, LODESTONE_API_COMMAND_CONTRACTS, LODESTONE_API_COMMAND_OUTCOMES,
12    LODESTONE_API_COMMAND_SEMANTICS, LODESTONE_API_FORMAT_VERSION, LODESTONE_API_RESPONSE_META_FORMAT_VERSION,
13    LODESTONE_API_SCHEMA_ID, LODESTONE_API_SPEC_SHA256, LODESTONE_API_STORAGE_PROFILE_ATTESTATION_FORMAT_VERSION,
14    LODESTONE_API_STREAM_CONTRACTS, RawJson, RecoveryAction, RequestBody, RequestEnvelope, RequestMeta,
15    ResponseEnvelope, ResponseMeta, ResultBody, StorageProfileAttestation, StorageProfileRole, U64, WorkloadClass,
16    command_for_result, validate_error_body,
17};
18use rustls::pki_types::{CertificateDer, ServerName};
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use tokio::sync::{Mutex as AsyncMutex, Notify, Semaphore};
22use tokio_rustls::TlsConnector;
23use tokio_rustls::client::TlsStream;
24
25use crate::config::{
26    CellTransportConfig, CellTransportIdentity, ClientConfigError, CollectionSessionConfig, PoolConfig, parse_identity,
27    parse_u64,
28};
29use crate::credential::{CredentialMaterial, CredentialProviderError};
30use crate::generated::{
31    GeneratedCollectionIndexRef, MAX_NATIVE_REQUEST_BYTES, MAX_NATIVE_RESPONSE_BYTES, request_collection_index_ref,
32    request_command,
33};
34
35const MAX_REQUEST_ID_BYTES: usize = 128;
36const MAX_CALL_TIMEOUT: Duration = Duration::from_secs(10 * 60);
37const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
38pub const MAX_VERIFIED_SESSION_METADATA: usize = 4_096;
39
40#[derive(Clone)]
41pub struct CallCancellation {
42    inner: Arc<CallCancellationInner>,
43}
44
45struct CallCancellationInner {
46    cancelled: AtomicBool,
47    notify: Notify,
48}
49
50impl CallCancellation {
51    #[must_use]
52    pub fn new() -> Self {
53        Self {
54            inner: Arc::new(CallCancellationInner {
55                cancelled: AtomicBool::new(false),
56                notify: Notify::new(),
57            }),
58        }
59    }
60
61    pub fn cancel(&self) {
62        if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
63            self.inner.notify.notify_waiters();
64        }
65    }
66
67    #[must_use]
68    pub fn is_cancelled(&self) -> bool {
69        self.inner.cancelled.load(Ordering::Acquire)
70    }
71
72    /// Waits until this shared call scope is cancelled.
73    pub async fn cancelled(&self) {
74        loop {
75            let notified = self.inner.notify.notified();
76            if self.is_cancelled() {
77                return;
78            }
79            notified.await;
80        }
81    }
82}
83
84impl Default for CallCancellation {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[derive(Clone)]
91pub struct CallOptions {
92    deadline: CallDeadline,
93    cancellation: CallCancellation,
94    index_name: Option<String>,
95    workload_class: Option<WorkloadClass>,
96}
97
98#[derive(Clone, Copy)]
99struct CallDeadline {
100    absolute_unix_millis: Option<u64>,
101    local: tokio::time::Instant,
102}
103
104impl CallOptions {
105    /// Creates a call scope whose absolute deadline is anchored now.
106    ///
107    /// Cloning these options preserves that original deadline; it never turns
108    /// the supplied duration into a new per-request timeout.
109    pub fn new(timeout: Duration) -> Result<Self, ClientError> {
110        Ok(Self {
111            deadline: CallDeadline::after(timeout)?,
112            cancellation: CallCancellation::new(),
113            index_name: None,
114            workload_class: None,
115        })
116    }
117
118    /// Creates a call scope from an existing absolute Unix-millisecond
119    /// deadline. The deadline must be in the future and no more than the
120    /// client maximum call budget away.
121    pub fn from_absolute_deadline_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
122        Ok(Self {
123            deadline: CallDeadline::from_absolute_unix_millis(absolute_unix_millis)?,
124            cancellation: CallCancellation::new(),
125            index_name: None,
126            workload_class: None,
127        })
128    }
129
130    #[must_use]
131    pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
132        self.cancellation = cancellation;
133        self
134    }
135
136    pub fn with_index_name(mut self, index_name: String) -> Result<Self, ClientError> {
137        validate_index_name(&index_name)?;
138        self.index_name = Some(index_name);
139        Ok(self)
140    }
141
142    /// Selects a workload class for this call. The server still requires the
143    /// authenticated role to grant the class and checks action compatibility.
144    #[must_use]
145    pub fn with_workload_class(mut self, workload_class: WorkloadClass) -> Self {
146        self.workload_class = Some(workload_class);
147        self
148    }
149
150    #[must_use]
151    pub const fn cancellation(&self) -> &CallCancellation {
152        &self.cancellation
153    }
154
155    /// Returns the exact deadline placed in every request envelope in this
156    /// call scope.
157    pub fn absolute_deadline_unix_millis(&self) -> Result<u64, ClientError> {
158        self.deadline.absolute_unix_millis.ok_or(ClientError::ClockUnavailable)
159    }
160
161    /// Returns the budget left in this call scope without resetting its
162    /// absolute deadline.
163    pub fn remaining(&self) -> Result<Duration, ClientError> {
164        self.local_deadline()?
165            .checked_duration_since(tokio::time::Instant::now())
166            .ok_or(ClientError::Transport {
167                kind: TransportErrorKind::Deadline,
168                disposition: SubmissionDisposition::NotSubmitted,
169            })
170    }
171
172    pub(crate) fn local_deadline(&self) -> Result<tokio::time::Instant, ClientError> {
173        self.absolute_deadline_unix_millis().map(|_| self.deadline.local)
174    }
175
176    pub(crate) fn for_resolution(&self) -> Self {
177        Self {
178            deadline: self.deadline,
179            cancellation: CallCancellation::new(),
180            index_name: self.index_name.clone(),
181            workload_class: self.workload_class,
182        }
183    }
184}
185
186impl CallDeadline {
187    fn after(timeout: Duration) -> Result<Self, ClientError> {
188        let timeout = normalized_timeout(timeout)?;
189        let local_now = tokio::time::Instant::now();
190        let wall_deadline = SystemTime::now()
191            .checked_add(timeout)
192            .ok_or(ClientError::ClockUnavailable)?;
193        let since_epoch = wall_deadline
194            .duration_since(UNIX_EPOCH)
195            .map_err(|_| ClientError::ClockUnavailable)?;
196        Ok(Self {
197            absolute_unix_millis: Some(duration_millis_ceil(since_epoch)?),
198            local: local_now + timeout,
199        })
200    }
201
202    fn from_absolute_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
203        let local_now = tokio::time::Instant::now();
204        let wall_deadline = UNIX_EPOCH
205            .checked_add(Duration::from_millis(absolute_unix_millis))
206            .ok_or(ClientError::InvalidCallOptions)?;
207        let remaining = wall_deadline
208            .duration_since(SystemTime::now())
209            .map_err(|_| ClientError::InvalidCallOptions)?;
210        if remaining.is_zero() || remaining > MAX_CALL_TIMEOUT {
211            return Err(ClientError::InvalidCallOptions);
212        }
213        Ok(Self {
214            absolute_unix_millis: Some(absolute_unix_millis),
215            local: local_now + remaining,
216        })
217    }
218}
219
220impl Default for CallOptions {
221    fn default() -> Self {
222        match Self::new(DEFAULT_CALL_TIMEOUT) {
223            Ok(options) => options,
224            Err(_) => Self {
225                deadline: CallDeadline {
226                    absolute_unix_millis: None,
227                    local: tokio::time::Instant::now(),
228                },
229                cancellation: CallCancellation::new(),
230                index_name: None,
231                workload_class: None,
232            },
233        }
234    }
235}
236
237#[derive(Clone, Debug, PartialEq)]
238pub struct ProfileVerification {
239    pub artifact_version: String,
240    pub principal_id: Identity,
241    pub collection_id: Identity,
242    pub policy_revision: U64,
243    pub cluster_id: Identity,
244    pub configuration_generation: U64,
245    pub binding_revision: U64,
246    pub schema_id: String,
247    pub schema_digest: lodestone_wire::Digest,
248    pub native_endpoints: Vec<String>,
249    pub selected_stream_endpoint: String,
250    pub storage_profile: StorageProfileAttestation,
251}
252
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub struct CellTransportMetricsSnapshot {
255    pub transport_count: usize,
256    pub live_connections: usize,
257    pub idle_connections: usize,
258    pub live_collection_sessions: usize,
259    pub session_cache_entries: usize,
260    pub session_cache_hits: u64,
261    pub session_cache_misses: u64,
262    pub session_cache_evictions: u64,
263    pub credential_fetches: u64,
264    pub bulkhead_saturation: u64,
265    pub isolation_failures: u64,
266}
267
268#[derive(Default)]
269struct CellTransportMetrics {
270    live_connections: AtomicUsize,
271    idle_connections: AtomicUsize,
272    live_collection_sessions: AtomicUsize,
273    session_cache_entries: AtomicUsize,
274    session_cache_hits: AtomicU64,
275    session_cache_misses: AtomicU64,
276    session_cache_evictions: AtomicU64,
277    credential_fetches: AtomicU64,
278    bulkhead_saturation: AtomicU64,
279    isolation_failures: AtomicU64,
280}
281
282impl CellTransportMetrics {
283    fn snapshot(&self) -> CellTransportMetricsSnapshot {
284        CellTransportMetricsSnapshot {
285            transport_count: 1,
286            live_connections: self.live_connections.load(Ordering::Relaxed),
287            idle_connections: self.idle_connections.load(Ordering::Relaxed),
288            live_collection_sessions: self.live_collection_sessions.load(Ordering::Relaxed),
289            session_cache_entries: self.session_cache_entries.load(Ordering::Relaxed),
290            session_cache_hits: self.session_cache_hits.load(Ordering::Relaxed),
291            session_cache_misses: self.session_cache_misses.load(Ordering::Relaxed),
292            session_cache_evictions: self.session_cache_evictions.load(Ordering::Relaxed),
293            credential_fetches: self.credential_fetches.load(Ordering::Relaxed),
294            bulkhead_saturation: self.bulkhead_saturation.load(Ordering::Relaxed),
295            isolation_failures: self.isolation_failures.load(Ordering::Relaxed),
296        }
297    }
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
301struct VerifiedSessionCacheKey {
302    collection_id: String,
303    principal_id: String,
304    profile_generation: String,
305    credential_id: String,
306    credential_generation: String,
307    secret_reference: String,
308}
309
310impl VerifiedSessionCacheKey {
311    fn from_config(config: &CollectionSessionConfig) -> Self {
312        let profile = config.profile.as_wire();
313        Self {
314            collection_id: profile.expected_collection_id.value.clone(),
315            principal_id: profile.expected_principal_id.value.clone(),
316            profile_generation: profile.profile_generation.value.clone(),
317            credential_id: profile.credential_id.value.clone(),
318            credential_generation: profile.credential_generation.value.clone(),
319            secret_reference: profile.secret_reference.clone(),
320        }
321    }
322}
323
324struct VerifiedSessionMetadata {
325    key: VerifiedSessionCacheKey,
326    verification: ProfileVerification,
327}
328
329#[derive(Default)]
330struct VerifiedSessionCache {
331    entries: VecDeque<VerifiedSessionMetadata>,
332}
333
334impl VerifiedSessionCache {
335    fn get(&mut self, key: &VerifiedSessionCacheKey, metrics: &CellTransportMetrics) -> Option<ProfileVerification> {
336        let Some(position) = self.entries.iter().position(|entry| &entry.key == key) else {
337            metrics.session_cache_misses.fetch_add(1, Ordering::Relaxed);
338            return None;
339        };
340        let entry = self.entries.remove(position)?;
341        let verification = entry.verification.clone();
342        self.entries.push_back(entry);
343        metrics.session_cache_hits.fetch_add(1, Ordering::Relaxed);
344        Some(verification)
345    }
346
347    fn insert(
348        &mut self,
349        key: VerifiedSessionCacheKey,
350        verification: ProfileVerification,
351        metrics: &CellTransportMetrics,
352    ) {
353        if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
354            let _ = self.entries.remove(position);
355        } else if self.entries.len() == MAX_VERIFIED_SESSION_METADATA {
356            let _ = self.entries.pop_front();
357            metrics.session_cache_evictions.fetch_add(1, Ordering::Relaxed);
358        }
359        self.entries.push_back(VerifiedSessionMetadata { key, verification });
360        metrics
361            .session_cache_entries
362            .store(self.entries.len(), Ordering::Relaxed);
363    }
364}
365
366#[derive(Clone)]
367pub struct CellTransport {
368    inner: Arc<CellTransportInner>,
369}
370
371struct CellTransportInner {
372    config: CellTransportConfig,
373    tls: Arc<rustls::ClientConfig>,
374    pool: AsyncMutex<Vec<NativeConnection>>,
375    connection_slots: Arc<Semaphore>,
376    next_endpoint: AtomicUsize,
377    verified_sessions: Mutex<VerifiedSessionCache>,
378    metrics: Arc<CellTransportMetrics>,
379}
380
381#[derive(Clone)]
382pub struct Client {
383    inner: Arc<ClientInner>,
384}
385
386pub type CollectionSession = Client;
387
388struct ClientInner {
389    transport: CellTransport,
390    config: CollectionSessionConfig,
391    verified: Mutex<Option<ProfileVerification>>,
392}
393
394impl Drop for ClientInner {
395    fn drop(&mut self) {
396        self.transport
397            .inner
398            .metrics
399            .live_collection_sessions
400            .fetch_sub(1, Ordering::Relaxed);
401    }
402}
403
404struct NativeConnection {
405    stream: TlsStream<TcpStream>,
406    metrics: Arc<CellTransportMetrics>,
407    idle_since: tokio::time::Instant,
408}
409
410impl Drop for NativeConnection {
411    fn drop(&mut self) {
412        self.metrics.live_connections.fetch_sub(1, Ordering::Relaxed);
413    }
414}
415
416impl CellTransport {
417    pub fn new(config: CellTransportConfig) -> Result<Self, ClientError> {
418        let tls = build_tls_config(&config)?;
419        let metrics = Arc::new(CellTransportMetrics::default());
420        Ok(Self {
421            inner: Arc::new(CellTransportInner {
422                connection_slots: Arc::new(Semaphore::new(config.pool.maximum_connections)),
423                config,
424                tls: Arc::new(tls),
425                pool: AsyncMutex::new(Vec::new()),
426                next_endpoint: AtomicUsize::new(0),
427                verified_sessions: Mutex::new(VerifiedSessionCache::default()),
428                metrics,
429            }),
430        })
431    }
432
433    #[must_use]
434    pub fn identity(&self) -> &CellTransportIdentity {
435        self.inner.config.identity()
436    }
437
438    #[must_use]
439    pub fn pool_config(&self) -> PoolConfig {
440        self.inner.config.pool
441    }
442
443    #[must_use]
444    pub fn metrics(&self) -> CellTransportMetricsSnapshot {
445        self.inner.metrics.snapshot()
446    }
447}
448
449impl Client {
450    /// Connects, authenticates, discovers capabilities, and verifies the exact
451    /// cluster, collection, schema, stream, and binding identities in the profile.
452    pub async fn connect(
453        transport: CellTransport,
454        config: CollectionSessionConfig,
455        options: CallOptions,
456    ) -> Result<Self, ClientError> {
457        let client = Self::new(transport, config)?;
458        client.verify_connection_profile(options).await?;
459        Ok(client)
460    }
461
462    pub fn new(transport: CellTransport, config: CollectionSessionConfig) -> Result<Self, ClientError> {
463        if let Err(error) = config.validate_transport(&transport.inner.config) {
464            transport
465                .inner
466                .metrics
467                .isolation_failures
468                .fetch_add(1, Ordering::Relaxed);
469            return Err(error.into());
470        }
471        let cache_key = VerifiedSessionCacheKey::from_config(&config);
472        let cached = transport
473            .inner
474            .verified_sessions
475            .lock()
476            .map_err(|_| ClientError::InternalStateUnavailable)?
477            .get(&cache_key, &transport.inner.metrics);
478        if let Some(verification) = &cached
479            && validate_cached_verification(&config, verification).is_err()
480        {
481            transport
482                .inner
483                .metrics
484                .isolation_failures
485                .fetch_add(1, Ordering::Relaxed);
486            return Err(ClientError::ProfileIdentityMismatch);
487        }
488        transport
489            .inner
490            .metrics
491            .live_collection_sessions
492            .fetch_add(1, Ordering::Relaxed);
493        Ok(Self {
494            inner: Arc::new(ClientInner {
495                transport,
496                config,
497                // INVARIANT: shared metadata never authorizes a fresh session.
498                // Every session performs its own authenticated discover/binding
499                // exchange before mutation, even after an exact cache hit.
500                verified: Mutex::new(None),
501            }),
502        })
503    }
504
505    #[must_use]
506    pub fn connection_profile(&self) -> &crate::ValidatedConnectionProfile {
507        &self.inner.config.profile
508    }
509
510    /// Returns a handle to the exact shared cell transport for creating
511    /// additional isolated Collection sessions.
512    #[must_use]
513    pub fn cell_transport(&self) -> CellTransport {
514        self.inner.transport.clone()
515    }
516
517    #[must_use]
518    pub fn profile_verification(&self) -> Option<ProfileVerification> {
519        self.inner.verified.lock().ok().and_then(|verified| verified.clone())
520    }
521
522    pub async fn verify_connection_profile(&self, options: CallOptions) -> Result<ProfileVerification, ClientError> {
523        let discovered = self.discover(DiscoverRequest {}, options.clone()).await?;
524        self.validate_discovery_observation(&discovered)?;
525        let binding = self.get_binding(GetBindingRequest {}, options).await?;
526        self.verify_authority_binding_observation(&discovered, &binding)
527    }
528
529    /// Validates one authenticated discovery observation against the selected
530    /// profile without performing another network call.
531    pub fn validate_discovery_observation(&self, discovered: &DiscoverResult) -> Result<(), ClientError> {
532        validate_discovery(&self.inner.config, discovered)
533    }
534
535    /// Validates and installs one authenticated discovery/binding cut without
536    /// performing another network call.
537    pub fn verify_authority_binding_observation(
538        &self,
539        discovered: &DiscoverResult,
540        binding: &GetBindingResult,
541    ) -> Result<ProfileVerification, ClientError> {
542        let verified = self.validate_authority_binding_observation(discovered, binding)?;
543        self.install_profile_verification(verified)
544    }
545
546    /// Validates one authenticated discovery/binding cut without installing
547    /// session authority. Connection diagnostics use this read-only probe so
548    /// they cannot make a later mutation eligible or alter its disposition.
549    pub fn validate_authority_binding_observation(
550        &self,
551        discovered: &DiscoverResult,
552        binding: &GetBindingResult,
553    ) -> Result<ProfileVerification, ClientError> {
554        validate_discovery(&self.inner.config, discovered)?;
555        validate_binding(&self.inner.config, &binding.binding, binding.derived, &binding.revision)?;
556        Ok(ProfileVerification {
557            artifact_version: discovered.artifact_version.clone(),
558            principal_id: discovered.principal_id.clone(),
559            collection_id: discovered.collection_id.clone(),
560            policy_revision: discovered.policy_revision.clone(),
561            cluster_id: binding.binding.cluster_id.clone(),
562            configuration_generation: binding.binding.configuration_generation.clone(),
563            binding_revision: binding.revision.clone(),
564            schema_id: binding.binding.schema_id.clone(),
565            schema_digest: binding.binding.schema_digest.clone(),
566            native_endpoints: binding.binding.native_endpoints.clone(),
567            selected_stream_endpoint: binding.binding.selected_stream_endpoint.clone(),
568            storage_profile: binding.binding.storage_profile.clone(),
569        })
570    }
571
572    fn install_profile_verification(&self, verified: ProfileVerification) -> Result<ProfileVerification, ClientError> {
573        let mut slot = self
574            .inner
575            .verified
576            .lock()
577            .map_err(|_| ClientError::InternalStateUnavailable)?;
578        *slot = Some(verified.clone());
579        self.inner
580            .transport
581            .inner
582            .verified_sessions
583            .lock()
584            .map_err(|_| ClientError::InternalStateUnavailable)?
585            .insert(
586                VerifiedSessionCacheKey::from_config(&self.inner.config),
587                verified.clone(),
588                &self.inner.transport.inner.metrics,
589            );
590        Ok(verified)
591    }
592
593    /// Dispatches one closed Lodestone API request body through the same validated
594    /// transport and recovery boundary used by every generated typed method.
595    ///
596    /// The caller supplies no envelope, collection, credential, or arbitrary
597    /// header. Those remain owned by the validated connection profile,
598    /// credential provider, and call options.
599    pub async fn call(&self, request: RequestBody, options: CallOptions) -> Result<ResultBody, ClientError> {
600        self.call_internal(request, options, None).await
601    }
602
603    /// Dispatches one request while preserving authenticated response
604    /// correlation metadata for operator diagnostics.
605    ///
606    /// The ordinary generated methods intentionally continue to expose only
607    /// their typed result or error. Diagnostic callers opt in to this wrapper;
608    /// no process-global "last error" state is introduced, so concurrent
609    /// requests cannot substitute one another's identifiers.
610    pub async fn call_with_diagnostics(
611        &self,
612        request: RequestBody,
613        options: CallOptions,
614    ) -> Result<ClientCallDiagnosticResult, ClientCallDiagnosticFailure> {
615        let response_meta = Arc::new(Mutex::new(None));
616        let result = self
617            .call_internal(request, options, Some(Arc::clone(&response_meta)))
618            .await;
619        let observed = response_meta.lock().ok().and_then(|value| value.clone());
620        match result {
621            Ok(result) => Ok(ClientCallDiagnosticResult {
622                result,
623                response_meta: observed,
624            }),
625            Err(error) => Err(ClientCallDiagnosticFailure {
626                error: Box::new(error),
627                response_meta: observed,
628            }),
629        }
630    }
631
632    async fn call_internal(
633        &self,
634        request: RequestBody,
635        options: CallOptions,
636        diagnostic_meta: Option<Arc<Mutex<Option<ResponseMeta>>>>,
637    ) -> Result<ResultBody, ClientError> {
638        let local_deadline = options.local_deadline()?;
639        if tokio::time::Instant::now() >= local_deadline {
640            return Err(ClientError::Transport {
641                kind: TransportErrorKind::Deadline,
642                disposition: SubmissionDisposition::NotSubmitted,
643            });
644        }
645        let command = request_command(&request);
646        let command_contract = LODESTONE_API_COMMAND_CONTRACTS
647            .iter()
648            .find(|contract| contract.command == command)
649            .ok_or(ClientError::MissingCommandContract)?;
650        let outcome_contract = LODESTONE_API_COMMAND_OUTCOMES
651            .iter()
652            .find(|contract| contract.command == command)
653            .ok_or(ClientError::MissingCommandContract)?;
654        let mutation = outcome_contract.possible_submission_states.contains(&"submitted");
655        if mutation && self.profile_verification().is_none() {
656            return Err(ClientError::ProfileNotVerified);
657        }
658        let index_name = resolve_index_name(&request, options.index_name.as_deref())?;
659        let correlation = random_identity()?;
660        let deadline = options.absolute_deadline_unix_millis()?;
661        let request_id = format!("r-{}", lower_hex(&correlation));
662        let workload_class = options
663            .workload_class
664            .unwrap_or_else(|| default_workload_class(command_contract.action));
665        let envelope = RequestEnvelope {
666            meta: RequestMeta {
667                format_version: LODESTONE_API_FORMAT_VERSION,
668                request_id: request_id.clone(),
669                correlation_id: Identity {
670                    value: lower_hex(&correlation),
671                },
672                absolute_deadline_unix_millis: U64 {
673                    value: deadline.to_string(),
674                },
675                workload_class,
676            },
677            request,
678        };
679        let payload = canonical_request(&envelope)?;
680        let started = Arc::new(AtomicBool::new(false));
681        let maximum_attempts = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
682        let mut attempt = 0_usize;
683        loop {
684            attempt = attempt.saturating_add(1);
685            let result = tokio::select! {
686                result = tokio::time::timeout_at(
687                    local_deadline,
688                    self.transport_attempt(
689                        &payload,
690                        &request_id,
691                        command,
692                        command_contract.action,
693                        workload_class,
694                        index_name.as_deref(),
695                        correlation,
696                        Arc::clone(&started),
697                        diagnostic_meta.as_ref(),
698                    ),
699                ) => match result {
700                    Ok(result) => result,
701                    Err(_) => Err(ClientError::Transport {
702                        kind: TransportErrorKind::Deadline,
703                        disposition: started_disposition(&started),
704                    }),
705                },
706                () = options.cancellation.cancelled() => Err(ClientError::Transport {
707                    kind: TransportErrorKind::Cancelled,
708                    disposition: started_disposition(&started),
709                }),
710            };
711            match result {
712                Err(ref error)
713                    if attempt < maximum_attempts
714                        && !options.cancellation.is_cancelled()
715                        && error.automatic_retry_admitted() =>
716                {
717                    continue;
718                }
719                result => return result,
720            }
721        }
722    }
723
724    #[allow(clippy::too_many_arguments)]
725    async fn transport_attempt(
726        &self,
727        payload: &[u8],
728        request_id: &str,
729        command: Command,
730        action: Action,
731        workload_class: WorkloadClass,
732        index_name: Option<&str>,
733        correlation: [u8; 16],
734        started: Arc<AtomicBool>,
735        diagnostic_meta: Option<&Arc<Mutex<Option<ResponseMeta>>>>,
736    ) -> Result<ResultBody, ClientError> {
737        // INVARIANT: Collection-local credential failures cannot consume or
738        // discard a shared cell connection. Material remains request-local and
739        // is fully encoded before this call enters the transport bulkhead.
740        let credential = self.credential().await?;
741        let native_credential = native_credential(&credential)?;
742        let session = PublicSession {
743            format_version: PUBLIC_SESSION_FORMAT_VERSION,
744            api_key_credential: native_credential.as_slice(),
745            collection_id: &self.inner.config.profile.as_wire().expected_collection_id.value,
746            request_id,
747            action: action_name(action),
748            workload_class,
749            index_name,
750            request_correlation_id: correlation,
751        };
752        let session = SensitiveBuffer(serde_json::to_vec(&session).map_err(|_| ClientError::RequestCodec)?);
753        if session.is_empty() || session.len() > MAX_PUBLIC_SESSION_BYTES {
754            return Err(ClientError::RequestTooLarge);
755        }
756        let mut wire = SensitiveBuffer::new();
757        wire.extend_u32(session.len())?;
758        wire.extend(session.as_slice());
759        wire.extend_u32(payload.len())?;
760        wire.extend(payload);
761
762        if self.inner.transport.inner.connection_slots.available_permits() == 0 {
763            self.inner
764                .transport
765                .inner
766                .metrics
767                .bulkhead_saturation
768                .fetch_add(1, Ordering::Relaxed);
769        }
770        let _permit = self
771            .inner
772            .transport
773            .inner
774            .connection_slots
775            .clone()
776            .acquire_owned()
777            .await
778            .map_err(|_| ClientError::Transport {
779                kind: TransportErrorKind::PoolClosed,
780                disposition: SubmissionDisposition::NotSubmitted,
781            })?;
782        let endpoint_index = self.inner.transport.inner.next_endpoint.fetch_add(1, Ordering::Relaxed)
783            % self.inner.transport.inner.config.native_endpoints.len();
784        let mut connection = {
785            let mut pool = self.inner.transport.inner.pool.lock().await;
786            // Every endpoint in the exact transport identity serves the same
787            // cell. Reuse any idle connection so endpoint rotation cannot
788            // create more live sockets than the transport connection bound.
789            // The public gateway bounds how long it waits for the next
790            // prelude, so an indefinitely idle pooled stream may already be
791            // half-closed. Retire aged entries before a request can cross an
792            // indeterminate write boundary on one of them.
793            let now = tokio::time::Instant::now();
794            let connection = loop {
795                match pool.pop() {
796                    Some(connection) if connection_idle_is_reusable(connection.idle_since, now) => {
797                        break Some(connection);
798                    }
799                    Some(_) => {}
800                    None => break None,
801                }
802            };
803            self.inner
804                .transport
805                .inner
806                .metrics
807                .idle_connections
808                .store(pool.len(), Ordering::Relaxed);
809            connection
810        };
811        if connection.is_none() {
812            connection = Some(self.connect_native(endpoint_index).await?);
813        }
814        let mut connection = connection.ok_or(ClientError::Transport {
815            kind: TransportErrorKind::Connect,
816            disposition: SubmissionDisposition::NotSubmitted,
817        })?;
818        started.store(true, Ordering::Release);
819        if connection.stream.write_all(wire.as_slice()).await.is_err() || connection.stream.flush().await.is_err() {
820            return Err(ClientError::Transport {
821                kind: TransportErrorKind::Write,
822                disposition: SubmissionDisposition::Indeterminate,
823            });
824        }
825        let response_length = connection.stream.read_u32().await.map_err(|_| ClientError::Transport {
826            kind: TransportErrorKind::Read,
827            disposition: SubmissionDisposition::Indeterminate,
828        })?;
829        let response_length = usize::try_from(response_length).map_err(|_| ClientError::ResponseTooLarge)?;
830        if response_length == 0 || response_length > MAX_NATIVE_RESPONSE_BYTES {
831            return Err(ClientError::ResponseTooLarge);
832        }
833        let mut response = vec![0; response_length];
834        connection
835            .stream
836            .read_exact(&mut response)
837            .await
838            .map_err(|_| ClientError::Transport {
839                kind: TransportErrorKind::Read,
840                disposition: SubmissionDisposition::Indeterminate,
841            })?;
842        let decoded: ResponseEnvelope = serde_json::from_slice(&response).map_err(|_| ClientError::ResponseCodec)?;
843        if serde_json::to_vec(&decoded).map_err(|_| ClientError::ResponseCodec)? != response {
844            return Err(ClientError::NonCanonicalResponse);
845        }
846        let result = match decoded {
847            ResponseEnvelope::Success { meta, result } if response_meta_matches(&meta, request_id, &correlation) => {
848                record_response_meta(diagnostic_meta, &meta)?;
849                if command_for_result(&result) != command {
850                    Err(ClientError::ResponseCommandMismatch)
851                } else {
852                    Ok(*result)
853                }
854            }
855            ResponseEnvelope::Success { .. } => Err(ClientError::ResponseIdentityMismatch),
856            ResponseEnvelope::Error { meta, error } => {
857                if !response_meta_matches(&meta, request_id, &correlation) {
858                    return Err(ClientError::ResponseIdentityMismatch);
859                }
860                record_response_meta(diagnostic_meta, &meta)?;
861                validate_error_body(&error).map_err(|_| ClientError::InvalidErrorShape)?;
862                let semantic = LODESTONE_API_COMMAND_SEMANTICS
863                    .iter()
864                    .find(|contract| contract.command == command)
865                    .ok_or(ClientError::MissingCommandContract)?;
866                if !semantic.admissible_errors.contains(&error.code) {
867                    return Err(ClientError::InvalidErrorShape);
868                }
869                Err(ClientError::Server(error))
870            }
871        };
872        // A success proves that the peer completed the request while keeping
873        // the shared byte stream synchronized. A typed refusal does not prove
874        // that the gateway kept the TLS session open: admission may close the
875        // connection immediately after writing the refusal. Discard refusal
876        // connections so a dependent follow-up (for example GetIndex
877        // NotFound followed by PrepareCreateIndex) cannot reuse a half-closed
878        // stream and consume its entire deadline waiting for a response.
879        if response_keeps_connection(&result) {
880            let mut pool = self.inner.transport.inner.pool.lock().await;
881            if pool.len() < self.pool_config().maximum_idle_connections {
882                connection.idle_since = tokio::time::Instant::now();
883                pool.push(connection);
884                self.inner
885                    .transport
886                    .inner
887                    .metrics
888                    .idle_connections
889                    .store(pool.len(), Ordering::Relaxed);
890            }
891        }
892        result
893    }
894
895    async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
896        let endpoint = self
897            .inner
898            .transport
899            .inner
900            .config
901            .native_endpoints
902            .get(endpoint_index)
903            .ok_or(ClientError::InvalidEndpoint)?;
904        let stream = tokio::time::timeout(
905            self.pool_config().connect_timeout,
906            TcpStream::connect((endpoint.host(), endpoint.port())),
907        )
908        .await
909        .map_err(|_| ClientError::Transport {
910            kind: TransportErrorKind::Connect,
911            disposition: SubmissionDisposition::NotSubmitted,
912        })?
913        .map_err(|_| ClientError::Transport {
914            kind: TransportErrorKind::Connect,
915            disposition: SubmissionDisposition::NotSubmitted,
916        })?;
917        let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
918            .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
919        let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
920        let stream = tokio::time::timeout(
921            self.pool_config().connect_timeout,
922            connector.connect(server_name, stream),
923        )
924        .await
925        .map_err(|_| ClientError::Transport {
926            kind: TransportErrorKind::Tls,
927            disposition: SubmissionDisposition::NotSubmitted,
928        })?
929        .map_err(|_| ClientError::Transport {
930            kind: TransportErrorKind::Tls,
931            disposition: SubmissionDisposition::NotSubmitted,
932        })?;
933        if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
934            return Err(ClientError::Transport {
935                kind: TransportErrorKind::Tls,
936                disposition: SubmissionDisposition::NotSubmitted,
937            });
938        }
939        self.inner
940            .transport
941            .inner
942            .metrics
943            .live_connections
944            .fetch_add(1, Ordering::Relaxed);
945        Ok(NativeConnection {
946            stream,
947            metrics: Arc::clone(&self.inner.transport.inner.metrics),
948            idle_since: tokio::time::Instant::now(),
949        })
950    }
951
952    pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
953        let profile = self.inner.config.profile.as_wire();
954        self.inner
955            .transport
956            .inner
957            .metrics
958            .credential_fetches
959            .fetch_add(1, Ordering::Relaxed);
960        let material = self
961            .inner
962            .config
963            .credentials
964            .provide(&profile.secret_reference)
965            .await
966            .map_err(ClientError::Credential)?;
967        if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
968            return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
969        }
970        Ok(material)
971    }
972
973    pub(crate) fn transport_config(&self) -> &CellTransportConfig {
974        &self.inner.transport.inner.config
975    }
976
977    pub(crate) fn expected_collection_id(&self) -> &str {
978        &self.inner.config.profile.as_wire().expected_collection_id.value
979    }
980
981    pub(crate) fn pool_config(&self) -> PoolConfig {
982        self.inner.transport.pool_config()
983    }
984}
985
986fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
987    result.is_ok()
988}
989
990fn record_response_meta(
991    destination: Option<&Arc<Mutex<Option<ResponseMeta>>>>,
992    meta: &ResponseMeta,
993) -> Result<(), ClientError> {
994    let Some(destination) = destination else {
995        return Ok(());
996    };
997    let mut destination = destination.lock().map_err(|_| ClientError::InternalStateUnavailable)?;
998    *destination = Some(meta.clone());
999    Ok(())
1000}
1001
1002const MAX_REUSABLE_CONNECTION_IDLE: Duration = Duration::from_secs(1);
1003
1004fn connection_idle_is_reusable(idle_since: tokio::time::Instant, now: tokio::time::Instant) -> bool {
1005    now.checked_duration_since(idle_since)
1006        .is_some_and(|idle| idle <= MAX_REUSABLE_CONNECTION_IDLE)
1007}
1008
1009const fn default_workload_class(action: Action) -> WorkloadClass {
1010    match action {
1011        Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
1012        Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
1013        Action::IdentityDiscovery
1014        | Action::IndexAdmin
1015        | Action::CatalogRead
1016        | Action::TaskRead
1017        | Action::DiagnosticsRead
1018        | Action::DiagnosticsCreate
1019        | Action::SecurityRead
1020        | Action::SecurityAdmin
1021        | Action::AuditRead
1022        | Action::QuotaRead
1023        | Action::QuotaAdmin
1024        | Action::BindingRead
1025        | Action::LifecycleAdmin
1026        | Action::AdmissionRead
1027        | Action::AdmissionAdmin
1028        | Action::UsageRead
1029        | Action::OperationRead
1030        | Action::OperationCancel
1031        | Action::CollectionExport
1032        | Action::CollectionRestore
1033        | Action::CollectionTransferExport
1034        | Action::CollectionTransferRestore
1035        | Action::CollectionStandbyAdmission
1036        | Action::CollectionStandbyActivation
1037        | Action::IndexExport
1038        | Action::IndexImport => WorkloadClass::BatchAnalytics,
1039    }
1040}
1041
1042#[derive(serde::Serialize)]
1043struct PublicSession<'a> {
1044    format_version: u16,
1045    api_key_credential: &'a [u8],
1046    collection_id: &'a str,
1047    request_id: &'a str,
1048    action: &'static str,
1049    workload_class: WorkloadClass,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    index_name: Option<&'a str>,
1052    request_correlation_id: [u8; 16],
1053}
1054
1055fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
1056    if envelope.meta.request_id.is_empty()
1057        || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
1058        || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
1059    {
1060        return Err(ClientError::InvalidRequestIdentity);
1061    }
1062    let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
1063    if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
1064        return Err(ClientError::RequestTooLarge);
1065    }
1066    let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
1067    if decoded != *envelope {
1068        return Err(ClientError::RequestCodec);
1069    }
1070    Ok(payload)
1071}
1072
1073fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
1074    let mut roots = rustls::RootCertStore::empty();
1075    for certificate in config.trust.certificates_der() {
1076        roots
1077            .add(CertificateDer::from(certificate.clone()))
1078            .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
1079    }
1080    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
1081    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
1082        .with_protocol_versions(&[&rustls::version::TLS13])
1083        .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
1084        .with_root_certificates(roots)
1085        .with_no_client_auth();
1086    tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
1087    Ok(tls)
1088}
1089
1090fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
1091    let profile = config.profile.as_wire();
1092    if discovered.collection_id != profile.expected_collection_id
1093        || discovered.principal_id != profile.expected_principal_id
1094        || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1095        || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1096    {
1097        return Err(ClientError::ProfileIdentityMismatch);
1098    }
1099    let expected = LODESTONE_API_STREAM_CONTRACTS
1100        .first()
1101        .ok_or(ClientError::MissingCommandContract)?;
1102    if discovered.streams.len() != 1
1103        || discovered.streams.first().is_none_or(|stream| {
1104            stream.name != expected.name
1105                || stream.transport != expected.transport
1106                || stream.service != expected.service
1107                || stream.method != expected.method
1108                || stream.endpoint != profile.selected_stream_endpoint
1109                || stream.auth_metadata != expected.auth_metadata
1110                || stream.auth_scheme != expected.auth_scheme
1111                || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1112                || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1113        })
1114    {
1115        return Err(ClientError::ProfileStreamMismatch);
1116    }
1117    Ok(())
1118}
1119
1120fn validate_binding(
1121    config: &CollectionSessionConfig,
1122    binding: &CoreBinding,
1123    derived: bool,
1124    revision: &U64,
1125) -> Result<(), ClientError> {
1126    let profile = config.profile.as_wire();
1127    if !derived
1128        || binding.cluster_id != profile.expected_cluster_id
1129        || binding.collection_id != profile.expected_collection_id
1130        || binding.native_endpoints != profile.native_endpoints
1131        || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1132        || binding.schema_id != profile.schema_id
1133        || binding.schema_digest != profile.schema_digest
1134        || parse_u64(&binding.configuration_generation.value).is_none_or(|generation| generation == 0)
1135        || parse_u64(&revision.value).is_none_or(|revision| revision == 0)
1136    {
1137        return Err(ClientError::ProfileIdentityMismatch);
1138    }
1139    validate_storage_profile(profile, binding, revision)?;
1140    Ok(())
1141}
1142
1143fn validate_storage_profile(
1144    profile: &lodestone_wire::ConnectionProfile,
1145    binding: &CoreBinding,
1146    binding_revision: &U64,
1147) -> Result<(), ClientError> {
1148    let attestation = &binding.storage_profile;
1149    let expected_roles = [
1150        StorageProfileRole::Control,
1151        StorageProfileRole::DefaultWal,
1152        StorageProfileRole::DefaultData,
1153    ];
1154    let roles_are_canonical = attestation.roles.len() == expected_roles.len()
1155        && attestation
1156            .roles
1157            .iter()
1158            .zip(expected_roles)
1159            .all(|(binding, expected)| binding.role == expected)
1160        && canonical_authority_groups(&attestation.roles);
1161    if attestation.format_version != u32::from(LODESTONE_API_STORAGE_PROFILE_ATTESTATION_FORMAT_VERSION)
1162        || parse_u64(&attestation.configuration_generation.value) != parse_u64(&binding.configuration_generation.value)
1163        || parse_u64(&binding_revision.value).is_none_or(|revision| revision == 0)
1164        || !roles_are_canonical
1165        || attestation.default_policy_digest.validate().is_err()
1166        || attestation.storage_profile_digest.validate().is_err()
1167        || attestation.attestation_digest.validate().is_err()
1168    {
1169        return Err(ClientError::ProfileIdentityMismatch);
1170    }
1171    if profile.storage_profile_expectation.as_ref().is_some_and(|expectation| {
1172        expectation
1173            .required_posture
1174            .is_some_and(|required| required != attestation.overall_posture)
1175            || expectation
1176                .expected_storage_profile_digest
1177                .as_ref()
1178                .is_some_and(|expected| expected != &attestation.storage_profile_digest)
1179    }) {
1180        return Err(ClientError::StorageProfileMismatch);
1181    }
1182    Ok(())
1183}
1184
1185fn canonical_authority_groups(roles: &[lodestone_wire::StorageProfileRoleBinding]) -> bool {
1186    let Some(first) = roles.first() else {
1187        return false;
1188    };
1189    if first.authority_group != 0 {
1190        return false;
1191    }
1192    let mut group_count = 1_usize;
1193    for role in roles.iter().skip(1) {
1194        let Ok(group) = usize::try_from(role.authority_group) else {
1195            return false;
1196        };
1197        if group < group_count {
1198            continue;
1199        }
1200        if group != group_count {
1201            return false;
1202        }
1203        group_count += 1;
1204    }
1205    true
1206}
1207
1208fn validate_cached_verification(
1209    config: &CollectionSessionConfig,
1210    verification: &ProfileVerification,
1211) -> Result<(), ClientError> {
1212    let profile = config.profile.as_wire();
1213    if verification.collection_id != profile.expected_collection_id
1214        || verification.principal_id != profile.expected_principal_id
1215        || verification.cluster_id != profile.expected_cluster_id
1216        || verification.schema_id != profile.schema_id
1217        || verification.schema_digest != profile.schema_digest
1218        || verification.native_endpoints != profile.native_endpoints
1219        || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1220        || verification
1221            .configuration_generation
1222            .value
1223            .parse::<u64>()
1224            .ok()
1225            .is_none_or(|generation| generation == 0)
1226        || verification
1227            .binding_revision
1228            .value
1229            .parse::<u64>()
1230            .ok()
1231            .is_none_or(|revision| revision == 0)
1232    {
1233        return Err(ClientError::ProfileIdentityMismatch);
1234    }
1235    let cached_binding = CoreBinding {
1236        cluster_id: verification.cluster_id.clone(),
1237        collection_id: verification.collection_id.clone(),
1238        configuration_generation: verification.configuration_generation.clone(),
1239        native_endpoints: verification.native_endpoints.clone(),
1240        selected_stream_endpoint: verification.selected_stream_endpoint.clone(),
1241        schema_id: verification.schema_id.clone(),
1242        schema_digest: verification.schema_digest.clone(),
1243        storage_profile: verification.storage_profile.clone(),
1244    };
1245    validate_storage_profile(profile, &cached_binding, &verification.binding_revision)?;
1246    Ok(())
1247}
1248
1249fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1250    let scope = request_collection_index_ref(request)?;
1251    let inferred = match &scope {
1252        GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1253        GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1254    };
1255    if let Some(supplied) = supplied {
1256        validate_index_name(supplied)?;
1257        if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1258            return Err(ClientError::CollectionIndexRefMismatch);
1259        }
1260    }
1261    let resolved = supplied.map(str::to_owned).or(inferred);
1262    if let Some(name) = &resolved {
1263        validate_index_name(name)?;
1264    }
1265    match scope {
1266        GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1267            return Err(ClientError::CollectionIndexRefRequired);
1268        }
1269        GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1270        GeneratedCollectionIndexRef::None
1271        | GeneratedCollectionIndexRef::Caller
1272        | GeneratedCollectionIndexRef::Exact(_) => {}
1273    }
1274    Ok(resolved)
1275}
1276
1277const fn action_name(action: Action) -> &'static str {
1278    match action {
1279        Action::IdentityDiscovery => "identity_discovery",
1280        Action::DataRead => "data_read",
1281        Action::DataWrite => "data_write",
1282        Action::Append => "append",
1283        Action::ResolveAppend => "resolve_append",
1284        Action::IndexAdmin => "index_admin",
1285        Action::CatalogRead => "catalog_read",
1286        Action::TaskRead => "task_read",
1287        Action::SubscriptionRead => "subscription_read",
1288        Action::DiagnosticsRead => "diagnostics_read",
1289        Action::DiagnosticsCreate => "diagnostics_create",
1290        Action::SecurityRead => "security_read",
1291        Action::SecurityAdmin => "security_admin",
1292        Action::AuditRead => "audit_read",
1293        Action::QuotaRead => "quota_read",
1294        Action::QuotaAdmin => "quota_admin",
1295        Action::BindingRead => "binding_read",
1296        Action::LifecycleAdmin => "lifecycle_admin",
1297        Action::AdmissionRead => "admission_read",
1298        Action::AdmissionAdmin => "admission_admin",
1299        Action::UsageRead => "usage_read",
1300        Action::OperationRead => "operation_read",
1301        Action::OperationCancel => "operation_cancel",
1302        Action::CollectionExport => "collection_export",
1303        Action::CollectionRestore => "collection_restore",
1304        Action::CollectionTransferExport => "collection_transfer_export",
1305        Action::CollectionTransferRestore => "collection_transfer_restore",
1306        Action::CollectionStandbyAdmission => "collection_standby_admission",
1307        Action::CollectionStandbyActivation => "collection_standby_activation",
1308        Action::IndexExport => "index_export",
1309        Action::IndexImport => "index_import",
1310    }
1311}
1312
1313fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1314    let identifier = parse_identity(&material.api_key_id().value)
1315        .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1316    let mut output = SensitiveBuffer::new();
1317    output.extend(identifier.to_string().as_bytes());
1318    output.extend(b":");
1319    output.extend(&base64_url_no_pad(material.secret().expose()));
1320    Ok(output)
1321}
1322
1323pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1324    let native = native_credential(material)?;
1325    let mut output = SensitiveBuffer::new();
1326    output.extend(b"ApiKey ");
1327    output.extend(&base64_standard(native.as_slice()));
1328    Ok(output)
1329}
1330
1331fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1332    base64_encode(
1333        bytes,
1334        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1335        false,
1336    )
1337}
1338
1339fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1340    base64_encode(
1341        bytes,
1342        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1343        true,
1344    )
1345}
1346
1347/// Converts exact UTF-8 text into the authoritative Lodestone API raw JSON wire
1348/// leaf without parsing or normalizing the text.
1349pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1350    let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1351    Ok(RawJson { bytes_base64 })
1352}
1353
1354fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1355    let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1356    for chunk in bytes.chunks(3) {
1357        let first = chunk[0];
1358        let second = chunk.get(1).copied().unwrap_or(0);
1359        let third = chunk.get(2).copied().unwrap_or(0);
1360        output.push(alphabet[usize::from(first >> 2)]);
1361        output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1362        if chunk.len() > 1 {
1363            output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1364        } else if padded {
1365            output.push(b'=');
1366        }
1367        if chunk.len() > 2 {
1368            output.push(alphabet[usize::from(third & 0x3f)]);
1369        } else if padded {
1370            output.push(b'=');
1371        }
1372    }
1373    output
1374}
1375
1376fn random_identity() -> Result<[u8; 16], ClientError> {
1377    let mut output = [0; 16];
1378    getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1379    if output == [0; 16] {
1380        return Err(ClientError::RandomUnavailable);
1381    }
1382    Ok(output)
1383}
1384
1385fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1386    if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1387        return Err(ClientError::InvalidCallOptions);
1388    }
1389    let mut millis = timeout.as_millis();
1390    if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1391        millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1392    }
1393    let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1394    let normalized = Duration::from_millis(millis);
1395    if normalized > MAX_CALL_TIMEOUT {
1396        return Err(ClientError::InvalidCallOptions);
1397    }
1398    Ok(normalized)
1399}
1400
1401fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1402    let mut millis = duration.as_millis();
1403    if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1404        millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1405    }
1406    u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1407}
1408
1409pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1410    if value.is_empty()
1411        || value.len() > 255
1412        || value
1413            .bytes()
1414            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1415    {
1416        return Err(ClientError::InvalidCollectionIndexRef);
1417    }
1418    Ok(())
1419}
1420
1421fn lower_hex(bytes: &[u8]) -> String {
1422    const HEX: &[u8; 16] = b"0123456789abcdef";
1423    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1424    for byte in bytes {
1425        output.push(char::from(HEX[usize::from(byte >> 4)]));
1426        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1427    }
1428    output
1429}
1430
1431pub(crate) struct SensitiveBuffer(Vec<u8>);
1432
1433impl SensitiveBuffer {
1434    fn new() -> Self {
1435        Self(Vec::new())
1436    }
1437
1438    fn extend(&mut self, bytes: &[u8]) {
1439        self.0.extend_from_slice(bytes);
1440    }
1441
1442    fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1443        let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1444        self.extend(&value.to_be_bytes());
1445        Ok(())
1446    }
1447
1448    fn is_empty(&self) -> bool {
1449        self.0.is_empty()
1450    }
1451
1452    fn len(&self) -> usize {
1453        self.0.len()
1454    }
1455
1456    pub(crate) fn as_slice(&self) -> &[u8] {
1457        &self.0
1458    }
1459}
1460
1461impl Drop for SensitiveBuffer {
1462    fn drop(&mut self) {
1463        self.0.fill(0);
1464        std::hint::black_box(&mut self.0);
1465    }
1466}
1467
1468fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1469    if started.load(Ordering::Acquire) {
1470        SubmissionDisposition::Indeterminate
1471    } else {
1472        SubmissionDisposition::NotSubmitted
1473    }
1474}
1475
1476#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1477pub enum SubmissionDisposition {
1478    NotSubmitted,
1479    Submitted,
1480    Resolved,
1481    KnownRetired,
1482    Indeterminate,
1483}
1484
1485#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1486pub enum TransportErrorKind {
1487    PoolClosed,
1488    Connect,
1489    Tls,
1490    Write,
1491    Read,
1492    Deadline,
1493    Cancelled,
1494}
1495
1496#[derive(Debug)]
1497pub enum ClientError {
1498    Config(ClientConfigError),
1499    Credential(CredentialProviderError),
1500    InvalidCallOptions,
1501    InvalidEndpoint,
1502    InvalidCollectionIndexRef,
1503    InvalidStateIndex,
1504    InvalidStateActionId,
1505    InvalidStateKey,
1506    InvalidStateBatch,
1507    UnexpectedStateResult,
1508    CollectionIndexRefRequired,
1509    CollectionIndexRefMismatch,
1510    ProfileNotVerified,
1511    ProfileIdentityMismatch,
1512    ProfileStreamMismatch,
1513    StorageProfileMismatch,
1514    MissingCommandContract,
1515    RequestCodec,
1516    RequestTooLarge,
1517    InvalidRequestIdentity,
1518    ResponseCodec,
1519    ResponseTooLarge,
1520    NonCanonicalResponse,
1521    ResponseIdentityMismatch,
1522    ResponseCommandMismatch,
1523    InvalidErrorShape,
1524    RandomUnavailable,
1525    ClockUnavailable,
1526    InternalStateUnavailable,
1527    Transport {
1528        kind: TransportErrorKind,
1529        disposition: SubmissionDisposition,
1530    },
1531    Server(ErrorBody),
1532}
1533
1534/// Typed native result plus authenticated response correlation metadata.
1535#[derive(Debug)]
1536pub struct ClientCallDiagnosticResult {
1537    pub result: ResultBody,
1538    pub response_meta: Option<ResponseMeta>,
1539}
1540
1541/// Typed client failure plus authenticated server response metadata when the
1542/// failure came from a well-formed native refusal.
1543#[derive(Debug)]
1544pub struct ClientCallDiagnosticFailure {
1545    pub error: Box<ClientError>,
1546    pub response_meta: Option<ResponseMeta>,
1547}
1548
1549impl ClientError {
1550    #[must_use]
1551    pub fn submission_disposition(&self) -> SubmissionDisposition {
1552        match self {
1553            Self::Transport { disposition, .. } => *disposition,
1554            Self::Server(error) => server_error_disposition(error),
1555            Self::UnexpectedStateResult
1556            | Self::ResponseCodec
1557            | Self::ResponseTooLarge
1558            | Self::NonCanonicalResponse
1559            | Self::ResponseIdentityMismatch
1560            | Self::ResponseCommandMismatch
1561            | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1562            _ => SubmissionDisposition::NotSubmitted,
1563        }
1564    }
1565
1566    /// Returns whether the current typed outcome permits an automatic replay.
1567    /// Indeterminate transport outcomes are always resolution-required; no
1568    /// command-level boolean may widen this decision.
1569    #[must_use]
1570    pub fn automatic_retry_admitted(&self) -> bool {
1571        matches!(
1572            self.retry_directive(),
1573            Some((
1574                ErrorDisposition::NotSubmitted,
1575                RecoveryAction::Retry | RecoveryAction::RetryAfter
1576            ))
1577        )
1578    }
1579
1580    fn retry_directive(&self) -> Option<(ErrorDisposition, RecoveryAction)> {
1581        match self {
1582            Self::Server(error) => Some((error.disposition, error.recovery)),
1583            Self::Transport {
1584                kind: TransportErrorKind::Deadline | TransportErrorKind::Cancelled,
1585                ..
1586            } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Stop)),
1587            Self::Transport {
1588                disposition: SubmissionDisposition::NotSubmitted,
1589                ..
1590            } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Retry)),
1591            Self::Transport { .. } => Some((ErrorDisposition::Indeterminate, RecoveryAction::Resolve)),
1592            _ => None,
1593        }
1594    }
1595
1596    pub(crate) fn is_deadline_exceeded(&self) -> bool {
1597        matches!(
1598            self,
1599            Self::Transport {
1600                kind: TransportErrorKind::Deadline,
1601                ..
1602            } | Self::Server(ErrorBody {
1603                code: lodestone_wire::ErrorCode::DeadlineExceeded,
1604                ..
1605            })
1606        )
1607    }
1608}
1609
1610fn server_error_disposition(error: &ErrorBody) -> SubmissionDisposition {
1611    match error.disposition {
1612        lodestone_wire::ErrorDisposition::NotSubmitted => SubmissionDisposition::NotSubmitted,
1613        lodestone_wire::ErrorDisposition::Submitted => SubmissionDisposition::Submitted,
1614        lodestone_wire::ErrorDisposition::Resolved => SubmissionDisposition::Resolved,
1615        lodestone_wire::ErrorDisposition::KnownRetired => SubmissionDisposition::KnownRetired,
1616        lodestone_wire::ErrorDisposition::Indeterminate => SubmissionDisposition::Indeterminate,
1617    }
1618}
1619
1620fn response_meta_matches(meta: &lodestone_wire::ResponseMeta, request_id: &str, correlation: &[u8; 16]) -> bool {
1621    meta.format_version == u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION)
1622        && meta.request_id == request_id
1623        && meta.correlation_id.value == lower_hex(correlation)
1624        && canonical_identity(&meta.diagnostic_id.value)
1625}
1626
1627fn canonical_identity(value: &str) -> bool {
1628    value.len() == 32
1629        && value
1630            .bytes()
1631            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1632        && value.bytes().any(|byte| byte != b'0')
1633}
1634
1635impl From<ClientConfigError> for ClientError {
1636    fn from(error: ClientConfigError) -> Self {
1637        Self::Config(error)
1638    }
1639}
1640
1641impl Display for ClientError {
1642    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1643        formatter.write_str(match self {
1644            Self::Config(_) => "Core client configuration is invalid",
1645            Self::Credential(_) => "Core credential material is unavailable or mismatched",
1646            Self::InvalidCallOptions => "Core call options are invalid",
1647            Self::InvalidEndpoint => "Core endpoint is invalid",
1648            Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1649            Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1650            Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1651            Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1652            Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1653            Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1654            Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1655            Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1656            Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1657            Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1658            Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1659            Self::StorageProfileMismatch => "Core storage profile differs from the connection profile expectation",
1660            Self::MissingCommandContract => "generated Core command metadata is incomplete",
1661            Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1662            Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1663            Self::InvalidRequestIdentity => "Core request identity is invalid",
1664            Self::ResponseCodec => "Core response is malformed or unsupported",
1665            Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1666            Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1667            Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1668            Self::ResponseCommandMismatch => "Core response returned another command result",
1669            Self::InvalidErrorShape => "Core error detail does not match its generated code",
1670            Self::RandomUnavailable => "secure request identity generation is unavailable",
1671            Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1672            Self::InternalStateUnavailable => "Core client verification state is unavailable",
1673            Self::Transport { .. } => "Core transport failed",
1674            Self::Server(_) => "Core returned a typed request error",
1675        })
1676    }
1677}
1678
1679impl Error for ClientError {}
1680
1681#[cfg(test)]
1682mod tests {
1683    use std::error::Error;
1684
1685    use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1686
1687    use super::*;
1688    use crate::SecretBytes;
1689
1690    #[test]
1691    fn diagnostic_metadata_is_request_local_and_exact() -> Result<(), Box<dyn Error>> {
1692        let first = Arc::new(Mutex::new(None::<ResponseMeta>));
1693        let second = Arc::new(Mutex::new(None::<ResponseMeta>));
1694        let meta = ResponseMeta {
1695            format_version: u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION),
1696            request_id: "r-diagnostic".to_owned(),
1697            correlation_id: Identity { value: "12".repeat(16) },
1698            diagnostic_id: Identity { value: "34".repeat(16) },
1699        };
1700        record_response_meta(Some(&first), &meta)?;
1701        assert_eq!(first.lock().map_err(|_| "first lock")?.as_ref(), Some(&meta));
1702        assert!(second.lock().map_err(|_| "second lock")?.is_none());
1703        Ok(())
1704    }
1705
1706    #[test]
1707    fn response_failures_preserve_indeterminate_submission() {
1708        for error in [
1709            ClientError::UnexpectedStateResult,
1710            ClientError::ResponseCodec,
1711            ClientError::ResponseTooLarge,
1712            ClientError::NonCanonicalResponse,
1713            ClientError::ResponseIdentityMismatch,
1714            ClientError::ResponseCommandMismatch,
1715            ClientError::InvalidErrorShape,
1716        ] {
1717            assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1718        }
1719        assert_eq!(
1720            ClientError::RequestCodec.submission_disposition(),
1721            SubmissionDisposition::NotSubmitted
1722        );
1723    }
1724
1725    #[test]
1726    fn response_metadata_requires_exact_echo_and_nonzero_canonical_diagnostic() {
1727        let correlation = [0x12; 16];
1728        let mut meta = lodestone_wire::ResponseMeta {
1729            format_version: u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION),
1730            request_id: "request-1".to_owned(),
1731            correlation_id: Identity {
1732                value: lower_hex(&correlation),
1733            },
1734            diagnostic_id: Identity { value: "34".repeat(16) },
1735        };
1736        assert!(response_meta_matches(&meta, "request-1", &correlation));
1737
1738        meta.correlation_id.value = "56".repeat(16);
1739        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1740        meta.correlation_id.value = lower_hex(&correlation);
1741
1742        meta.diagnostic_id.value = "00".repeat(16);
1743        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1744        meta.diagnostic_id.value = "AB".repeat(16);
1745        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1746        meta.diagnostic_id.value = "34".repeat(16);
1747
1748        meta.request_id = "request-2".to_owned();
1749        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1750        meta.request_id = "request-1".to_owned();
1751        meta.format_version = meta.format_version.saturating_add(1);
1752        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1753    }
1754
1755    fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1756        VerifiedSessionCacheKey {
1757            collection_id: format!("{collection:032x}"),
1758            principal_id: format!("{principal:032x}"),
1759            profile_generation: "1".to_owned(),
1760            credential_id: format!("{credential:032x}"),
1761            credential_generation: "1".to_owned(),
1762            secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1763        }
1764    }
1765
1766    fn verification(collection: u128, principal: u128) -> ProfileVerification {
1767        ProfileVerification {
1768            artifact_version: "development".to_owned(),
1769            principal_id: Identity {
1770                value: format!("{principal:032x}"),
1771            },
1772            collection_id: Identity {
1773                value: format!("{collection:032x}"),
1774            },
1775            policy_revision: U64 { value: "1".to_owned() },
1776            cluster_id: Identity {
1777                value: "00000000000000000000000000000001".to_owned(),
1778            },
1779            configuration_generation: U64 { value: "1".to_owned() },
1780            binding_revision: U64 { value: "1".to_owned() },
1781            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1782            schema_digest: lodestone_wire::Digest {
1783                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1784            },
1785            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1786            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1787            storage_profile: test_storage_profile(),
1788        }
1789    }
1790
1791    fn test_storage_profile() -> StorageProfileAttestation {
1792        StorageProfileAttestation {
1793            format_version: u32::from(LODESTONE_API_STORAGE_PROFILE_ATTESTATION_FORMAT_VERSION),
1794            configuration_generation: U64 { value: "1".to_owned() },
1795            overall_posture: lodestone_wire::StorageOverallPosture::FilesystemDevelopment,
1796            roles: vec![
1797                lodestone_wire::StorageProfileRoleBinding {
1798                    role: StorageProfileRole::Control,
1799                    authority_group: 0,
1800                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1801                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1802                },
1803                lodestone_wire::StorageProfileRoleBinding {
1804                    role: StorageProfileRole::DefaultWal,
1805                    authority_group: 0,
1806                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1807                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1808                },
1809                lodestone_wire::StorageProfileRoleBinding {
1810                    role: StorageProfileRole::DefaultData,
1811                    authority_group: 0,
1812                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1813                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1814                },
1815            ],
1816            default_durability: lodestone_wire::ResourceDurabilityClass::RegionalObject,
1817            default_storage: lodestone_wire::ResourceStorageClass::Standard,
1818            default_policy_digest: lodestone_wire::Digest { value: "11".repeat(32) },
1819            storage_profile_digest: lodestone_wire::Digest { value: "22".repeat(32) },
1820            attestation_digest: lodestone_wire::Digest { value: "33".repeat(32) },
1821        }
1822    }
1823
1824    fn test_session_with_storage_expectation(
1825        posture: Option<lodestone_wire::StorageOverallPosture>,
1826        digest: Option<&str>,
1827    ) -> Result<CollectionSessionConfig, Box<dyn Error>> {
1828        let profile = crate::ValidatedConnectionProfile::new(lodestone_wire::ConnectionProfile {
1829            format_version: u32::from(lodestone_wire::LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION),
1830            profile_generation: U64 { value: "1".to_owned() },
1831            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1832            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1833            tls_server_name: "core.example".to_owned(),
1834            tls_trust_sha256: lodestone_wire::Digest { value: "44".repeat(32) },
1835            expected_cluster_id: Identity {
1836                value: "00000000000000000000000000000001".to_owned(),
1837            },
1838            expected_collection_id: Identity {
1839                value: "00000000000000000000000000000002".to_owned(),
1840            },
1841            expected_principal_id: Identity {
1842                value: "00000000000000000000000000000003".to_owned(),
1843            },
1844            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1845            schema_digest: lodestone_wire::Digest {
1846                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1847            },
1848            credential_id: Identity {
1849                value: "00000000000000000000000000000004".to_owned(),
1850            },
1851            credential_generation: U64 { value: "1".to_owned() },
1852            credential_expires_at: None,
1853            secret_reference: "memory://storage-profile-test".to_owned(),
1854            storage_profile_expectation: Some(lodestone_wire::StorageProfileExpectation {
1855                format_version: u32::from(lodestone_wire::LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION),
1856                required_posture: posture,
1857                expected_storage_profile_digest: digest.map(|value| lodestone_wire::Digest {
1858                    value: value.to_owned(),
1859                }),
1860            }),
1861        })?;
1862        Ok(CollectionSessionConfig {
1863            profile,
1864            credentials: Arc::new(crate::FileCredentialProvider),
1865        })
1866    }
1867
1868    fn test_binding(storage_profile: StorageProfileAttestation) -> CoreBinding {
1869        CoreBinding {
1870            cluster_id: Identity {
1871                value: "00000000000000000000000000000001".to_owned(),
1872            },
1873            collection_id: Identity {
1874                value: "00000000000000000000000000000002".to_owned(),
1875            },
1876            configuration_generation: U64 { value: "1".to_owned() },
1877            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1878            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1879            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1880            schema_digest: lodestone_wire::Digest {
1881                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1882            },
1883            storage_profile,
1884        }
1885    }
1886
1887    #[test]
1888    fn storage_profile_expectations_fail_before_session_installation() -> Result<(), Box<dyn Error>> {
1889        let correct = test_session_with_storage_expectation(
1890            Some(lodestone_wire::StorageOverallPosture::FilesystemDevelopment),
1891            Some(&"22".repeat(32)),
1892        )?;
1893        let binding = test_binding(test_storage_profile());
1894        let revision = U64 { value: "1".to_owned() };
1895        validate_binding(&correct, &binding, true, &revision)?;
1896
1897        let wrong_posture = test_session_with_storage_expectation(
1898            Some(lodestone_wire::StorageOverallPosture::ProviderObjectStorage),
1899            None,
1900        )?;
1901        assert!(matches!(
1902            validate_binding(&wrong_posture, &binding, true, &revision),
1903            Err(ClientError::StorageProfileMismatch)
1904        ));
1905        assert_eq!(
1906            ClientError::StorageProfileMismatch.submission_disposition(),
1907            SubmissionDisposition::NotSubmitted
1908        );
1909
1910        let wrong_digest = test_session_with_storage_expectation(None, Some(&"55".repeat(32)))?;
1911        assert!(matches!(
1912            validate_binding(&wrong_digest, &binding, true, &revision),
1913            Err(ClientError::StorageProfileMismatch)
1914        ));
1915        Ok(())
1916    }
1917
1918    #[test]
1919    fn malformed_storage_profile_shape_is_an_identity_mismatch() -> Result<(), Box<dyn Error>> {
1920        let config = test_session_with_storage_expectation(
1921            Some(lodestone_wire::StorageOverallPosture::FilesystemDevelopment),
1922            None,
1923        )?;
1924        let revision = U64 { value: "1".to_owned() };
1925        let mut malformed = test_storage_profile();
1926        malformed.roles.swap(0, 1);
1927        assert!(matches!(
1928            validate_binding(&config, &test_binding(malformed), true, &revision),
1929            Err(ClientError::ProfileIdentityMismatch)
1930        ));
1931
1932        let mut malformed = test_storage_profile();
1933        malformed.roles[1].authority_group = 2;
1934        assert!(matches!(
1935            validate_binding(&config, &test_binding(malformed), true, &revision),
1936            Err(ClientError::ProfileIdentityMismatch)
1937        ));
1938
1939        let mut malformed = test_storage_profile();
1940        malformed.configuration_generation = U64 { value: "2".to_owned() };
1941        assert!(matches!(
1942            validate_binding(&config, &test_binding(malformed), true, &revision),
1943            Err(ClientError::ProfileIdentityMismatch)
1944        ));
1945        Ok(())
1946    }
1947
1948    #[test]
1949    fn automatic_retry_uses_only_typed_disposition_and_recovery() {
1950        let transport = |disposition| ClientError::Transport {
1951            kind: TransportErrorKind::Read,
1952            disposition,
1953        };
1954        assert!(transport(SubmissionDisposition::NotSubmitted).automatic_retry_admitted());
1955        assert!(!transport(SubmissionDisposition::Submitted).automatic_retry_admitted());
1956        assert!(!transport(SubmissionDisposition::Indeterminate).automatic_retry_admitted());
1957
1958        let server = |disposition, recovery| {
1959            ClientError::Server(ErrorBody {
1960                code: lodestone_wire::ErrorCode::TemporarilyUnavailable,
1961                message: "typed retry probe".to_owned(),
1962                disposition,
1963                recovery,
1964                details: lodestone_wire::ErrorDetail::TemporarilyUnavailable {
1965                    retry_after_millis: None,
1966                },
1967            })
1968        };
1969        assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::Retry).automatic_retry_admitted());
1970        assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::RetryAfter).automatic_retry_admitted());
1971        assert!(!server(ErrorDisposition::Indeterminate, RecoveryAction::Resolve).automatic_retry_admitted());
1972        assert!(!server(ErrorDisposition::NotSubmitted, RecoveryAction::Stop).automatic_retry_admitted());
1973    }
1974
1975    #[test]
1976    fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1977        let options = CallOptions::new(Duration::from_secs(5))?;
1978        let absolute = options.absolute_deadline_unix_millis()?;
1979        let local = options.local_deadline()?;
1980
1981        // These clones stand for discovery, binding, prepare, submit, and
1982        // resolution. None may derive a fresh duration from the current time.
1983        for stage in [
1984            options.clone(),
1985            options.clone(),
1986            options.clone(),
1987            options.clone(),
1988            options.for_resolution(),
1989        ] {
1990            assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1991            assert_eq!(stage.local_deadline()?, local);
1992        }
1993
1994        let resolution = options.for_resolution();
1995        options.cancellation().cancel();
1996        assert!(options.cancellation().is_cancelled());
1997        assert!(!resolution.cancellation().is_cancelled());
1998        assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1999        Ok(())
2000    }
2001
2002    #[test]
2003    fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
2004        let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
2005        let now_millis = u64::try_from(now.as_millis())?;
2006        let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
2007        let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
2008        assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
2009
2010        let too_far = now_millis
2011            .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
2012            .and_then(|value| value.checked_add(1_000))
2013            .ok_or("test deadline overflow")?;
2014        assert!(matches!(
2015            CallOptions::from_absolute_deadline_unix_millis(too_far),
2016            Err(ClientError::InvalidCallOptions)
2017        ));
2018        assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
2019        Ok(())
2020    }
2021
2022    #[test]
2023    fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
2024        let material = CredentialMaterial::new(
2025            Identity {
2026                value: "00000000000000000000000000000001".to_owned(),
2027            },
2028            U64 { value: "1".to_owned() },
2029            SecretBytes::from_array([0; 32]),
2030        )?;
2031        assert_eq!(
2032            native_credential(&material)?.as_slice(),
2033            b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
2034        );
2035        assert_eq!(
2036            authorization_value(&material)?.as_slice(),
2037            b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
2038        );
2039        Ok(())
2040    }
2041
2042    #[test]
2043    fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
2044        let refusal = Err(ClientError::Server(ErrorBody {
2045            code: lodestone_wire::ErrorCode::PermissionDenied,
2046            message: "denied".to_owned(),
2047            disposition: lodestone_wire::ErrorDisposition::NotSubmitted,
2048            recovery: lodestone_wire::RecoveryAction::Stop,
2049            details: lodestone_wire::ErrorDetail::PermissionDenied {
2050                action: None,
2051                workload_class: None,
2052            },
2053        }));
2054        assert!(!response_keeps_connection(&refusal));
2055        let failure = Err(ClientError::Transport {
2056            kind: TransportErrorKind::Read,
2057            disposition: SubmissionDisposition::Indeterminate,
2058        });
2059        assert!(!response_keeps_connection(&failure));
2060    }
2061
2062    #[test]
2063    fn connection_idle_age_is_fenced_below_the_gateway_prelude_timeout() {
2064        let now = tokio::time::Instant::now();
2065        assert!(connection_idle_is_reusable(now - MAX_REUSABLE_CONNECTION_IDLE, now));
2066        assert!(!connection_idle_is_reusable(
2067            now - MAX_REUSABLE_CONNECTION_IDLE - Duration::from_millis(1),
2068            now
2069        ));
2070    }
2071
2072    #[test]
2073    fn verified_session_cache_is_bounded_and_never_crosses_principals() {
2074        let metrics = CellTransportMetrics::default();
2075        let mut cache = VerifiedSessionCache::default();
2076        let first = cache_key(1, 10, 100);
2077        let other_principal = cache_key(1, 11, 100);
2078        assert_ne!(first, other_principal);
2079        cache.insert(first.clone(), verification(1, 10), &metrics);
2080        assert!(cache.get(&other_principal, &metrics).is_none());
2081        assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
2082
2083        for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
2084            let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
2085            cache.insert(
2086                cache_key(identity, identity, identity),
2087                verification(identity, identity),
2088                &metrics,
2089            );
2090        }
2091        assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
2092        assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
2093        assert_eq!(
2094            metrics.session_cache_entries.load(Ordering::Relaxed),
2095            MAX_VERIFIED_SESSION_METADATA
2096        );
2097        assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
2098        assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
2099    }
2100
2101    #[test]
2102    fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
2103        fn index(name: &str, incarnation: &str) -> Resource {
2104            Resource {
2105                kind: "index".to_owned(),
2106                name: name.to_owned(),
2107                incarnation: Identity {
2108                    value: incarnation.to_owned(),
2109                },
2110            }
2111        }
2112
2113        fn request(indexes: Vec<Resource>) -> RequestBody {
2114            RequestBody::OpenPit(OpenPitRequest {
2115                indexes,
2116                keep_alive_millis: U64 {
2117                    value: "1000".to_owned(),
2118                },
2119            })
2120        }
2121
2122        let first = index("orders", "00000000000000000000000000000001");
2123        let second = index("events", "00000000000000000000000000000002");
2124        assert!(matches!(
2125            resolve_index_name(&request(Vec::new()), None),
2126            Err(ClientError::CollectionIndexRefRequired)
2127        ));
2128        assert!(matches!(
2129            resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
2130            Ok(None)
2131        ));
2132        assert!(matches!(
2133            resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
2134            Err(ClientError::CollectionIndexRefMismatch)
2135        ));
2136        assert!(matches!(
2137            resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
2138            Err(ClientError::CollectionIndexRefMismatch)
2139        ));
2140    }
2141
2142    #[test]
2143    fn generated_get_index_is_collection_scoped() {
2144        let request = RequestBody::GetIndex(GetIndexRequest {
2145            name: "events".to_owned(),
2146            expected_incarnation: None,
2147        });
2148
2149        assert!(matches!(resolve_index_name(&request, None), Ok(None)));
2150        assert!(matches!(
2151            resolve_index_name(&request, Some("events")),
2152            Err(ClientError::CollectionIndexRefMismatch)
2153        ));
2154    }
2155}