Skip to main content

hyphae_server/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{
4    future::Future,
5    net::SocketAddr,
6    path::PathBuf,
7    sync::{
8        Arc, Mutex,
9        atomic::{AtomicBool, Ordering},
10    },
11    time::Duration,
12};
13
14use axum::{
15    Router,
16    body::{self, Body},
17    extract::{Extension, Request, State},
18    http::{HeaderMap, HeaderValue, StatusCode, header},
19    middleware::{self, Next},
20    response::{IntoResponse, Response},
21    routing::{get, post},
22};
23use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
24use hyphae_contracts::v1::{
25    CapabilitiesV1, CommitReceiptV1, DefineLexicalIndexRequestV1, DefineVectorSpaceRequestV1,
26    DeleteRequestV1, DeleteVectorsRequestV1, ExactAbstentionReasonV1, ExactAbstentionV1,
27    ExactRetrievalMatchV1, ExactRetrievalOutcomeV1, ExactRetrievalRequestV1,
28    ExactRetrievalResponseV1, GetRequestV1, GetResponseV1, HealthV1, HybridAbstentionV1,
29    HybridBranchAbsenceV1, HybridExplanationV1, HybridRetrievalMatchV1, HybridRetrievalOutcomeV1,
30    HybridRetrievalRequestV1, HybridRetrievalResponseV1, LexicalAbstentionReasonV1,
31    LexicalAbstentionV1, LexicalFieldContributionV1, LexicalRetrievalMatchV1,
32    LexicalRetrievalOutcomeV1, LexicalRetrievalRequestV1, LexicalRetrievalResponseV1,
33    LexicalTermContributionV1, ProofV1, PutRequestV1, PutVectorsRequestV1, QueryRequestV1,
34    QueryResponseV1, RecordV1, RetrievalProofV1, VectorMetricV1, WitnessV1, decode_key_hex,
35    encode_hex,
36};
37use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName, current_version};
38use hyphae_engine::{
39    EngineError, ExactRetrievalProofArtifact, HybridRetrievalProofArtifact, HyphaeEngine,
40    LexicalRetrievalProofArtifact, ProofError, ProvenResult, ResultProofArtifact,
41};
42use hyphae_query::FieldPath;
43use hyphae_retrieval::{
44    ExactAbstentionReason, ExactRetrievalOutcome, ExactRetrievalRequest, HybridBranchAbsence,
45    HybridOutcome, HybridRequest, LexicalAbstentionReason, LexicalField, LexicalIndexDefinition,
46    LexicalOutcome, LexicalRequest,
47};
48use hyphae_storage::{
49    AppendOutcome, LogError, MaterializedIndexError, SnapshotError, StorageError, verify_snapshot,
50};
51use serde::{Serialize, de::DeserializeOwned};
52use tokio::{net::TcpListener, sync::Semaphore};
53use tokio_util::io::ReaderStream;
54use uuid::Uuid;
55
56use crate::{ApiError, BearerToken, ServerConfig, ServerError, ServerLimits};
57
58const FEATURES: [&str; 14] = [
59    "atomic_batch",
60    "deterministic_query",
61    "durable_vectors",
62    "exact_retrieval",
63    "hybrid_retrieval",
64    "idempotency",
65    "kv",
66    "lexical_retrieval",
67    "offline_result_proof",
68    "offline_retrieval_proof",
69    "provider_free_lexical",
70    "snapshot_witness",
71    "structured_aggregation",
72    "typed_abstention",
73];
74
75#[derive(Clone, Debug)]
76struct RequestId(String);
77
78struct ServerState {
79    engine: Arc<Mutex<HyphaeEngine>>,
80    data_dir: PathBuf,
81    limits: ServerLimits,
82    bearer_token: Option<BearerToken>,
83    admission: Arc<Semaphore>,
84    ready: AtomicBool,
85}
86
87/// Opened optional HTTP surface owning exactly one embedded Hyphae engine.
88pub struct HyphaeServer {
89    bind: SocketAddr,
90    state: Arc<ServerState>,
91}
92
93impl HyphaeServer {
94    /// Validates secure defaults and opens the exclusively owned engine.
95    ///
96    /// No socket is opened by this method. In particular, a non-loopback bind
97    /// without authentication fails here before [`Self::bind`].
98    ///
99    /// # Errors
100    ///
101    /// Returns a configuration, data-directory lock, recovery, or corruption
102    /// error.
103    pub fn open(config: ServerConfig) -> Result<Self, ServerError> {
104        config.validate()?;
105        let opened = HyphaeEngine::open(config.data_dir())?;
106        let data_dir = opened.engine.data_path().to_path_buf();
107        Ok(Self {
108            bind: config.bind,
109            state: Arc::new(ServerState {
110                engine: Arc::new(Mutex::new(opened.engine)),
111                data_dir,
112                admission: Arc::new(Semaphore::new(config.limits.concurrent_operations)),
113                ready: AtomicBool::new(true),
114                limits: config.limits,
115                bearer_token: config.bearer_token,
116            }),
117        })
118    }
119
120    /// Opens the configured TCP listener and prepares graceful serving.
121    ///
122    /// # Errors
123    ///
124    /// Returns an operating-system socket bind failure.
125    pub async fn bind(self) -> Result<BoundServer, ServerError> {
126        let listener = TcpListener::bind(self.bind)
127            .await
128            .map_err(|source| ServerError::Bind {
129                address: self.bind,
130                source,
131            })?;
132        let local_addr = listener.local_addr().map_err(|source| ServerError::Bind {
133            address: self.bind,
134            source,
135        })?;
136        Ok(BoundServer {
137            listener,
138            local_addr,
139            router: build_router(self.state),
140        })
141    }
142
143    #[cfg(test)]
144    fn test_router(&self) -> Router {
145        build_router(Arc::clone(&self.state))
146    }
147}
148
149/// Successfully bound HTTP service awaiting a shutdown signal.
150pub struct BoundServer {
151    listener: TcpListener,
152    local_addr: SocketAddr,
153    router: Router,
154}
155
156impl BoundServer {
157    /// Returns the actual local address, including an assigned ephemeral port.
158    pub fn local_addr(&self) -> SocketAddr {
159        self.local_addr
160    }
161
162    /// Serves until the supplied graceful-shutdown future resolves.
163    ///
164    /// # Errors
165    ///
166    /// Returns an HTTP listener/service I/O failure.
167    pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), ServerError>
168    where
169        F: Future<Output = ()> + Send + 'static,
170    {
171        axum::serve(self.listener, self.router)
172            .with_graceful_shutdown(shutdown)
173            .await
174            .map_err(ServerError::Serve)
175    }
176}
177
178fn build_router(state: Arc<ServerState>) -> Router {
179    let public = Router::new()
180        .route("/v1/capabilities", get(capabilities))
181        .route("/v1/health/live", get(liveness))
182        .route("/v1/health/ready", get(readiness));
183    let protected = Router::new()
184        .route("/v1/kv/put", post(put_records))
185        .route("/v1/kv/get", post(get_record))
186        .route("/v1/kv/delete", post(delete_records))
187        .route("/v1/query", post(query_records))
188        .route("/v1/vector-spaces/define", post(define_vector_space))
189        .route("/v1/vectors/put", post(put_vectors))
190        .route("/v1/vectors/delete", post(delete_vectors))
191        .route("/v1/retrieve/exact", post(retrieve_exact))
192        .route("/v1/lexical-indexes/define", post(define_lexical_index))
193        .route("/v1/retrieve/lexical", post(retrieve_lexical))
194        .route("/v1/retrieve/hybrid", post(retrieve_hybrid))
195        .route(
196            "/v1/witnesses/{checkpoint_sequence}/{snapshot_digest}",
197            get(download_witness),
198        )
199        .route_layer(middleware::from_fn_with_state(
200            Arc::clone(&state),
201            authenticate,
202        ));
203
204    public
205        .merge(protected)
206        .fallback(route_not_found)
207        .method_not_allowed_fallback(method_not_allowed)
208        .with_state(state)
209        .layer(middleware::from_fn(assign_request_id))
210}
211
212async fn assign_request_id(mut request: Request, next: Next) -> Response {
213    let request_id = RequestId(Uuid::now_v7().to_string());
214    request.extensions_mut().insert(request_id.clone());
215    let mut response = next.run(request).await;
216    if let Ok(value) = HeaderValue::from_str(&request_id.0) {
217        response.headers_mut().insert("x-request-id", value);
218    }
219    response
220}
221
222async fn authenticate(
223    State(state): State<Arc<ServerState>>,
224    request: Request,
225    next: Next,
226) -> Response {
227    let Some(expected) = &state.bearer_token else {
228        return next.run(request).await;
229    };
230    let request_id = request_id(&request);
231    if bearer_candidate(request.headers()).is_some_and(|candidate| expected.verifies(candidate)) {
232        return next.run(request).await;
233    }
234    ApiError::new(
235        StatusCode::UNAUTHORIZED,
236        "unauthorized",
237        "valid bearer authentication is required",
238        request_id,
239    )
240    .into_response()
241}
242
243fn bearer_candidate(headers: &HeaderMap) -> Option<&[u8]> {
244    let mut values = headers.get_all(header::AUTHORIZATION).iter();
245    let value = values.next()?;
246    if values.next().is_some() {
247        return None;
248    }
249    let value = value.as_bytes();
250    let separator = value.iter().position(|byte| *byte == b' ')?;
251    if !value[..separator].eq_ignore_ascii_case(b"bearer") {
252        return None;
253    }
254    let candidate = &value[separator.saturating_add(1)..];
255    (!candidate.is_empty()).then_some(candidate)
256}
257
258async fn liveness(
259    State(state): State<Arc<ServerState>>,
260    Extension(request_id): Extension<RequestId>,
261) -> Result<Response, ApiError> {
262    bounded_json(
263        &HealthV1 {
264            status: "live".to_owned(),
265        },
266        &state,
267        &request_id.0,
268    )
269}
270
271async fn readiness(
272    State(state): State<Arc<ServerState>>,
273    Extension(request_id): Extension<RequestId>,
274) -> Result<Response, ApiError> {
275    if !state.ready.load(Ordering::Acquire) {
276        return Err(ApiError::unavailable(&request_id.0));
277    }
278    bounded_json(
279        &HealthV1 {
280            status: "ready".to_owned(),
281        },
282        &state,
283        &request_id.0,
284    )
285}
286
287async fn capabilities(
288    State(state): State<Arc<ServerState>>,
289    Extension(request_id): Extension<RequestId>,
290) -> Result<Response, ApiError> {
291    let version = current_version();
292    bounded_json(
293        &CapabilitiesV1 {
294            api_version: version.api.to_owned(),
295            disk_format_version: version.disk_format,
296            features: FEATURES.iter().map(ToString::to_string).collect(),
297            limits: state.limits.as_contract(),
298        },
299        &state,
300        &request_id.0,
301    )
302}
303
304async fn put_records(
305    State(state): State<Arc<ServerState>>,
306    Extension(request_id): Extension<RequestId>,
307    request: Request,
308) -> Result<Response, ApiError> {
309    let request: PutRequestV1 = parse_json(request, &state, &request_id.0).await?;
310    validate_batch(request.records.len(), &state, &request_id.0)?;
311    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
312    let records = request
313        .records
314        .iter()
315        .map(RecordV1::to_domain)
316        .collect::<Result<Vec<_>, _>>()
317        .map_err(|_| ApiError::invalid(&request_id.0))?;
318    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
319        capture_write_outcome(engine.put_records(transaction_id, &records))
320    })
321    .await?;
322    if outcome.requires_recovery {
323        state.ready.store(false, Ordering::Release);
324    }
325    bounded_json(&receipt(outcome.append), &state, &request_id.0)
326}
327
328async fn delete_records(
329    State(state): State<Arc<ServerState>>,
330    Extension(request_id): Extension<RequestId>,
331    request: Request,
332) -> Result<Response, ApiError> {
333    let request: DeleteRequestV1 = parse_json(request, &state, &request_id.0).await?;
334    validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
335    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
336    let keys = request
337        .keys_hex
338        .iter()
339        .map(|key| decode_key_hex(key))
340        .collect::<Result<Vec<_>, _>>()
341        .map_err(|_| ApiError::invalid(&request_id.0))?;
342    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
343        let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
344        capture_write_outcome(engine.delete_records(transaction_id, &keys))
345    })
346    .await?;
347    if outcome.requires_recovery {
348        state.ready.store(false, Ordering::Release);
349    }
350    bounded_json(&receipt(outcome.append), &state, &request_id.0)
351}
352
353async fn get_record(
354    State(state): State<Arc<ServerState>>,
355    Extension(request_id): Extension<RequestId>,
356    request: Request,
357) -> Result<Response, ApiError> {
358    let request: GetRequestV1 = parse_json(request, &state, &request_id.0).await?;
359    let key = decode_key_hex(&request.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?;
360    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
361        engine.get_record_with_proof(&key)
362    })
363    .await?;
364    let proof = proof_transport(&artifact, &state, &request_id.0)?;
365    let ProvenResult::Get(record) = artifact.proof.result() else {
366        return Err(ApiError::internal(&request_id.0));
367    };
368    let response = GetResponseV1 {
369        found: record.is_some(),
370        record: record.as_ref().map(RecordV1::from_domain),
371        proof,
372    };
373    bounded_json(&response, &state, &request_id.0)
374}
375
376async fn query_records(
377    State(state): State<Arc<ServerState>>,
378    Extension(request_id): Extension<RequestId>,
379    request: Request,
380) -> Result<Response, ApiError> {
381    let request: QueryRequestV1 = parse_json(request, &state, &request_id.0).await?;
382    let timeout = requested_timeout(request.timeout_ms, &state, &request_id.0)?;
383    let query = request
384        .to_domain()
385        .map_err(|_| ApiError::invalid(&request_id.0))?;
386    let mut execution_limits = state.limits.query.clone();
387    execution_limits.timeout = timeout;
388    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
389        engine.query_with_proof(&query, &execution_limits)
390    })
391    .await?;
392    let proof = proof_transport(&artifact, &state, &request_id.0)?;
393    let ProvenResult::Query(result) = artifact.proof.result() else {
394        return Err(ApiError::internal(&request_id.0));
395    };
396    bounded_json(
397        &QueryResponseV1::from_domain(result, proof),
398        &state,
399        &request_id.0,
400    )
401}
402
403async fn define_vector_space(
404    State(state): State<Arc<ServerState>>,
405    Extension(request_id): Extension<RequestId>,
406    request: Request,
407) -> Result<Response, ApiError> {
408    let request: DefineVectorSpaceRequestV1 = parse_json(request, &state, &request_id.0).await?;
409    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
410    if request.vector_space.metric != VectorMetricV1::CosineQ15Nanos {
411        return Err(ApiError::invalid(&request_id.0));
412    }
413    let name = VectorSpaceName::new(request.vector_space.name)
414        .map_err(|_| ApiError::invalid(&request_id.0))?;
415    let definition = VectorSpaceDefinition::cosine(name, request.vector_space.dimension)
416        .map_err(|_| ApiError::invalid(&request_id.0))?;
417    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
418        capture_write_outcome(engine.define_vector_space(transaction_id, definition))
419    })
420    .await?;
421    if outcome.requires_recovery {
422        state.ready.store(false, Ordering::Release);
423    }
424    bounded_json(&receipt(outcome.append), &state, &request_id.0)
425}
426
427async fn put_vectors(
428    State(state): State<Arc<ServerState>>,
429    Extension(request_id): Extension<RequestId>,
430    request: Request,
431) -> Result<Response, ApiError> {
432    let request: PutVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
433    validate_batch(request.vectors.len(), &state, &request_id.0)?;
434    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
435    let space =
436        VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
437    let vectors = request
438        .vectors
439        .into_iter()
440        .map(|vector| {
441            Ok((
442                decode_key_hex(&vector.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?,
443                Q15Vector::new(vector.values).map_err(|_| ApiError::invalid(&request_id.0))?,
444            ))
445        })
446        .collect::<Result<Vec<_>, ApiError>>()?;
447    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
448        capture_write_outcome(engine.put_vectors(transaction_id, &space, &vectors))
449    })
450    .await?;
451    if outcome.requires_recovery {
452        state.ready.store(false, Ordering::Release);
453    }
454    bounded_json(&receipt(outcome.append), &state, &request_id.0)
455}
456
457async fn delete_vectors(
458    State(state): State<Arc<ServerState>>,
459    Extension(request_id): Extension<RequestId>,
460    request: Request,
461) -> Result<Response, ApiError> {
462    let request: DeleteVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
463    validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
464    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
465    let space =
466        VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
467    let keys = request
468        .keys_hex
469        .iter()
470        .map(|key| decode_key_hex(key))
471        .collect::<Result<Vec<_>, _>>()
472        .map_err(|_| ApiError::invalid(&request_id.0))?;
473    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
474        let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
475        capture_write_outcome(engine.delete_vectors(transaction_id, &space, &keys))
476    })
477    .await?;
478    if outcome.requires_recovery {
479        state.ready.store(false, Ordering::Release);
480    }
481    bounded_json(&receipt(outcome.append), &state, &request_id.0)
482}
483
484async fn retrieve_exact(
485    State(state): State<Arc<ServerState>>,
486    Extension(request_id): Extension<RequestId>,
487    request: Request,
488) -> Result<Response, ApiError> {
489    let request: ExactRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
490    let timeout = requested_retrieval_timeout(request.timeout_ms, &state, &request_id.0)?;
491    let request = exact_request(request, &request_id.0)?;
492    let mut limits = state.limits.exact_retrieval.clone();
493    limits.timeout = timeout;
494    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
495        engine.retrieve_exact_with_proof(&request, &limits)
496    })
497    .await?;
498    let proof = retrieval_proof_transport(&artifact, &state, &request_id.0)?;
499    let response = ExactRetrievalResponseV1 {
500        outcome: exact_outcome_transport(artifact.proof.outcome()),
501        proof,
502    };
503    bounded_json(&response, &state, &request_id.0)
504}
505
506fn exact_request(
507    request: ExactRetrievalRequestV1,
508    request_id: &str,
509) -> Result<ExactRetrievalRequest, ApiError> {
510    Ok(ExactRetrievalRequest {
511        vector_space: VectorSpaceName::new(request.vector_space)
512            .map_err(|_| ApiError::invalid(request_id))?,
513        query: Q15Vector::new(request.query).map_err(|_| ApiError::invalid(request_id))?,
514        limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
515        minimum_score_nanos: request.minimum_score_nanos,
516        minimum_margin_nanos: request.minimum_margin_nanos,
517    })
518}
519
520fn lexical_request(
521    request: LexicalRetrievalRequestV1,
522    request_id: &str,
523) -> Result<LexicalRequest, ApiError> {
524    Ok(LexicalRequest {
525        index: VectorSpaceName::new(request.lexical_index)
526            .map_err(|_| ApiError::invalid(request_id))?,
527        query: request.query,
528        limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
529    })
530}
531
532async fn define_lexical_index(
533    State(state): State<Arc<ServerState>>,
534    Extension(request_id): Extension<RequestId>,
535    request: Request,
536) -> Result<Response, ApiError> {
537    let request: DefineLexicalIndexRequestV1 = parse_json(request, &state, &request_id.0).await?;
538    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
539    let name = VectorSpaceName::new(request.lexical_index.name)
540        .map_err(|_| ApiError::invalid(&request_id.0))?;
541    let fields = request
542        .lexical_index
543        .fields
544        .into_iter()
545        .map(|field| {
546            Ok(LexicalField {
547                path: FieldPath::new(field.path),
548                weight_micros: field.weight_micros,
549            })
550        })
551        .collect::<Result<Vec<_>, ApiError>>()?;
552    let definition =
553        LexicalIndexDefinition::new(name, fields).map_err(|_| ApiError::invalid(&request_id.0))?;
554    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
555        capture_write_outcome(engine.define_lexical_index(transaction_id, definition))
556    })
557    .await?;
558    if outcome.requires_recovery {
559        state.ready.store(false, Ordering::Release);
560    }
561    bounded_json(&receipt(outcome.append), &state, &request_id.0)
562}
563
564async fn retrieve_lexical(
565    State(state): State<Arc<ServerState>>,
566    Extension(request_id): Extension<RequestId>,
567    request: Request,
568) -> Result<Response, ApiError> {
569    let request: LexicalRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
570    let timeout = requested_lexical_timeout(request.timeout_ms, &state, &request_id.0)?;
571    let request = lexical_request(request, &request_id.0)?;
572    let mut limits = state.limits.lexical_retrieval.clone();
573    limits.timeout = timeout;
574    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
575        engine.retrieve_lexical_with_proof(&request, &limits)
576    })
577    .await?;
578    let proof = lexical_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
579    bounded_json(
580        &LexicalRetrievalResponseV1 {
581            outcome: lexical_outcome_transport(artifact.proof.outcome()),
582            proof,
583        },
584        &state,
585        &request_id.0,
586    )
587}
588
589async fn retrieve_hybrid(
590    State(state): State<Arc<ServerState>>,
591    Extension(request_id): Extension<RequestId>,
592    request: Request,
593) -> Result<Response, ApiError> {
594    let request: HybridRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
595    let lexical_timeout =
596        requested_lexical_timeout(request.lexical.timeout_ms, &state, &request_id.0)?;
597    let vector_timeout =
598        requested_retrieval_timeout(request.vector.timeout_ms, &state, &request_id.0)?;
599    let lexical_request = lexical_request(request.lexical, &request_id.0)?;
600    let vector_request = exact_request(request.vector, &request_id.0)?;
601    let hybrid_request = HybridRequest {
602        lexical_weight: request.lexical_weight,
603        vector_weight: request.vector_weight,
604        limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(&request_id.0))?,
605    };
606    if hybrid_request.limit > state.limits.lexical_retrieval.max_returned {
607        return Err(ApiError::limit(&request_id.0));
608    }
609    let mut lexical_limits = state.limits.lexical_retrieval.clone();
610    lexical_limits.timeout = lexical_timeout;
611    let mut vector_limits = state.limits.exact_retrieval.clone();
612    vector_limits.timeout = vector_timeout;
613    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
614        engine.retrieve_hybrid_with_proof(
615            &lexical_request,
616            &lexical_limits,
617            &vector_request,
618            &vector_limits,
619            &hybrid_request,
620        )
621    })
622    .await?;
623    let proof = hybrid_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
624    bounded_json(
625        &HybridRetrievalResponseV1 {
626            outcome: hybrid_outcome_transport(artifact.proof.outcome()),
627            proof,
628        },
629        &state,
630        &request_id.0,
631    )
632}
633
634async fn download_witness(
635    State(state): State<Arc<ServerState>>,
636    Extension(request_id): Extension<RequestId>,
637    request: Request,
638) -> Result<Response, ApiError> {
639    let (sequence, expected_digest) =
640        parse_witness_path(request.uri().path()).ok_or_else(|| ApiError::invalid(&request_id.0))?;
641    let path = state
642        .data_dir
643        .join("snapshots")
644        .join(format!("snapshot-{sequence:020}.hysnap"));
645    let permit = Arc::clone(&state.admission)
646        .try_acquire_owned()
647        .map_err(|_| busy(&request_id.0))?;
648    let verification_path = path.clone();
649    let verified = tokio::task::spawn_blocking(move || verify_snapshot(verification_path)).await;
650    drop(permit);
651    let info = match verified {
652        Ok(Ok(info)) => info,
653        Ok(Err(SnapshotError::Io(source))) if source.kind() == std::io::ErrorKind::NotFound => {
654            return Err(not_found(&request_id.0));
655        }
656        Ok(Err(_)) | Err(_) => return Err(ApiError::internal(&request_id.0)),
657    };
658    if info.checkpoint_sequence != sequence || info.snapshot_digest != expected_digest {
659        return Err(not_found(&request_id.0));
660    }
661    if info.file_bytes > state.limits.witness_bytes {
662        return Err(ApiError::limit(&request_id.0));
663    }
664    let file = tokio::fs::File::open(&path)
665        .await
666        .map_err(|_| ApiError::internal(&request_id.0))?;
667    let stream = ReaderStream::new(file);
668    Response::builder()
669        .status(StatusCode::OK)
670        .header(header::CONTENT_TYPE, "application/octet-stream")
671        .header(header::CONTENT_LENGTH, info.file_bytes)
672        .header(
673            "digest",
674            format!("blake3={}", encode_hex(&info.snapshot_digest)),
675        )
676        .body(Body::from_stream(stream))
677        .map_err(|_| ApiError::internal(&request_id.0))
678}
679
680async fn parse_json<T: DeserializeOwned>(
681    request: Request,
682    state: &ServerState,
683    request_id: &str,
684) -> Result<T, ApiError> {
685    if !is_json_content_type(request.headers()) {
686        return Err(ApiError::new(
687            StatusCode::UNSUPPORTED_MEDIA_TYPE,
688            "unsupported_media_type",
689            "content type must be application/json",
690            request_id,
691        ));
692    }
693    let bytes = tokio::time::timeout(
694        state.limits.request_body_timeout,
695        body::to_bytes(request.into_body(), state.limits.request_body_bytes),
696    )
697    .await
698    .map_err(|_| {
699        ApiError::new(
700            StatusCode::REQUEST_TIMEOUT,
701            "timeout",
702            "request body deadline elapsed without starting an operation",
703            request_id,
704        )
705    })?
706    .map_err(|_| ApiError::payload_too_large(request_id))?;
707    if bytes.is_empty() {
708        return Err(ApiError::invalid(request_id));
709    }
710    let value: serde_json::Value =
711        serde_json::from_slice(&bytes).map_err(|_| ApiError::invalid(request_id))?;
712    validate_json_shape(&value, state.limits.json_depth, state.limits.json_nodes)
713        .map_err(|()| ApiError::limit(request_id))?;
714    serde_json::from_value(value).map_err(|_| ApiError::invalid(request_id))
715}
716
717fn validate_json_shape(
718    root: &serde_json::Value,
719    maximum_depth: usize,
720    maximum_nodes: usize,
721) -> Result<(), ()> {
722    let mut stack = vec![(root, 0_usize)];
723    let mut nodes = 0_usize;
724    while let Some((value, depth)) = stack.pop() {
725        nodes = nodes.checked_add(1).ok_or(())?;
726        if nodes > maximum_nodes || depth > maximum_depth {
727            return Err(());
728        }
729        match value {
730            serde_json::Value::Array(values) => {
731                let next_depth = depth.checked_add(1).ok_or(())?;
732                stack.extend(values.iter().map(|value| (value, next_depth)));
733            }
734            serde_json::Value::Object(values) => {
735                let next_depth = depth.checked_add(1).ok_or(())?;
736                stack.extend(values.values().map(|value| (value, next_depth)));
737            }
738            serde_json::Value::Null
739            | serde_json::Value::Bool(_)
740            | serde_json::Value::Number(_)
741            | serde_json::Value::String(_) => {}
742        }
743    }
744    Ok(())
745}
746
747fn is_json_content_type(headers: &HeaderMap) -> bool {
748    let Some(value) = headers.get(header::CONTENT_TYPE) else {
749        return false;
750    };
751    let Ok(value) = value.to_str() else {
752        return false;
753    };
754    let media_type = value.split(';').next().unwrap_or_default().trim();
755    let media_type = media_type.to_ascii_lowercase();
756    media_type == "application/json"
757        || (media_type.starts_with("application/") && media_type.ends_with("+json"))
758}
759
760fn validate_batch(length: usize, state: &ServerState, request_id: &str) -> Result<(), ApiError> {
761    if length == 0 {
762        return Err(ApiError::invalid(request_id));
763    }
764    if length > state.limits.batch_items {
765        return Err(ApiError::limit(request_id));
766    }
767    Ok(())
768}
769
770fn parse_transaction_id(value: Option<&str>, request_id: &str) -> Result<Uuid, ApiError> {
771    value.map_or_else(
772        || Ok(Uuid::now_v7()),
773        |value| Uuid::parse_str(value).map_err(|_| ApiError::invalid(request_id)),
774    )
775}
776
777fn requested_timeout(
778    requested_ms: Option<u64>,
779    state: &ServerState,
780    request_id: &str,
781) -> Result<Duration, ApiError> {
782    let maximum_ms = u64::try_from(state.limits.query.timeout.as_millis()).unwrap_or(u64::MAX);
783    let requested_ms = requested_ms.unwrap_or(maximum_ms);
784    if requested_ms == 0 {
785        return Err(ApiError::invalid(request_id));
786    }
787    if requested_ms > maximum_ms {
788        return Err(ApiError::limit(request_id));
789    }
790    Ok(Duration::from_millis(requested_ms))
791}
792
793fn requested_retrieval_timeout(
794    requested_ms: Option<u64>,
795    state: &ServerState,
796    request_id: &str,
797) -> Result<Duration, ApiError> {
798    let maximum_ms =
799        u64::try_from(state.limits.exact_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
800    let requested_ms = requested_ms.unwrap_or(maximum_ms);
801    if requested_ms == 0 {
802        return Err(ApiError::invalid(request_id));
803    }
804    if requested_ms > maximum_ms {
805        return Err(ApiError::limit(request_id));
806    }
807    Ok(Duration::from_millis(requested_ms))
808}
809
810fn requested_lexical_timeout(
811    requested_ms: Option<u64>,
812    state: &ServerState,
813    request_id: &str,
814) -> Result<Duration, ApiError> {
815    let maximum_ms =
816        u64::try_from(state.limits.lexical_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
817    let requested_ms = requested_ms.unwrap_or(maximum_ms);
818    if requested_ms == 0 {
819        return Err(ApiError::invalid(request_id));
820    }
821    if requested_ms > maximum_ms {
822        return Err(ApiError::limit(request_id));
823    }
824    Ok(Duration::from_millis(requested_ms))
825}
826
827async fn with_engine<T, F>(
828    state: Arc<ServerState>,
829    request_id: &str,
830    operation: F,
831) -> Result<T, ApiError>
832where
833    T: Send + 'static,
834    F: FnOnce(&mut HyphaeEngine) -> Result<T, EngineError> + Send + 'static,
835{
836    if !state.ready.load(Ordering::Acquire) {
837        return Err(ApiError::unavailable(request_id));
838    }
839    let _permit = Arc::clone(&state.admission)
840        .try_acquire_owned()
841        .map_err(|_| busy(request_id))?;
842    let engine = Arc::clone(&state.engine);
843    let result = tokio::task::spawn_blocking(move || {
844        let mut engine = engine.lock().map_err(|_| EngineTaskError::Poisoned)?;
845        operation(&mut engine).map_err(EngineTaskError::Engine)
846    })
847    .await;
848    match result {
849        Ok(Ok(value)) => Ok(value),
850        Ok(Err(EngineTaskError::Engine(source))) => {
851            if engine_error_requires_recovery(&source) {
852                state.ready.store(false, Ordering::Release);
853            }
854            Err(ApiError::from_engine(source, request_id))
855        }
856        Ok(Err(EngineTaskError::Poisoned)) | Err(_) => {
857            state.ready.store(false, Ordering::Release);
858            Err(ApiError::internal(request_id))
859        }
860    }
861}
862
863enum EngineTaskError {
864    Engine(EngineError),
865    Poisoned,
866}
867
868struct WriteOutcome {
869    append: AppendOutcome,
870    requires_recovery: bool,
871}
872
873fn capture_write_outcome(
874    result: Result<AppendOutcome, EngineError>,
875) -> Result<WriteOutcome, EngineError> {
876    match result {
877        Ok(append) => Ok(WriteOutcome {
878            append,
879            requires_recovery: false,
880        }),
881        Err(EngineError::Storage(StorageError::CommittedButNotIndexed { receipt, .. })) => {
882            Ok(WriteOutcome {
883                append: AppendOutcome::Committed(receipt),
884                requires_recovery: true,
885            })
886        }
887        Err(source) => Err(source),
888    }
889}
890
891fn engine_error_requires_recovery(error: &EngineError) -> bool {
892    if matches!(
893        error,
894        EngineError::Proof(ProofError::ProofLimitExceeded { .. } | ProofError::LengthOverflow)
895    ) {
896        return false;
897    }
898    match error {
899        EngineError::Storage(StorageError::Index { source }) => {
900            materialized_index_error_requires_recovery(source)
901        }
902        EngineError::Storage(
903            StorageError::CommittedButNotIndexed { .. }
904            | StorageError::StaleIndex
905            | StorageError::Snapshot { .. }
906            | StorageError::DataDirectory(_)
907            | StorageError::Log(LogError::Poisoned),
908        )
909        | EngineError::Proof(_) => true,
910        _ => false,
911    }
912}
913
914fn materialized_index_error_requires_recovery(error: &MaterializedIndexError) -> bool {
915    !matches!(
916        error,
917        MaterializedIndexError::Vector(_)
918            | MaterializedIndexError::UnknownVectorSpace { .. }
919            | MaterializedIndexError::VectorSpaceConflict { .. }
920            | MaterializedIndexError::Lexical(_)
921            | MaterializedIndexError::LexicalIndexConflict { .. }
922            | MaterializedIndexError::UnknownLexicalIndex { .. }
923            | MaterializedIndexError::VectorCandidateBudgetExceeded { .. }
924            | MaterializedIndexError::VectorByteBudgetExceeded { .. }
925    )
926}
927
928fn proof_transport(
929    artifact: &ResultProofArtifact,
930    state: &ServerState,
931    request_id: &str,
932) -> Result<ProofV1, ApiError> {
933    let encoded = artifact
934        .proof
935        .to_bytes()
936        .map_err(|source| ApiError::from_engine(EngineError::Proof(source), request_id))?;
937    if encoded.len() > state.limits.proof_bytes
938        || artifact.snapshot.file_bytes > state.limits.witness_bytes
939    {
940        return Err(ApiError::result_too_large(request_id));
941    }
942    let anchor = artifact.proof.anchor();
943    let snapshot_digest = encode_hex(&anchor.snapshot_digest);
944    Ok(ProofV1 {
945        encoding: "base64".to_owned(),
946        data: BASE64.encode(encoded),
947        proof_digest: encode_hex(&artifact.proof.proof_digest()),
948        anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
949        checkpoint_sequence: anchor.checkpoint_sequence,
950        checkpoint_digest: anchor
951            .checkpoint_digest
952            .as_ref()
953            .map(|digest| encode_hex(digest)),
954        snapshot_digest: snapshot_digest.clone(),
955        witness: WitnessV1 {
956            path: format!(
957                "/v1/witnesses/{}/{}",
958                anchor.checkpoint_sequence, snapshot_digest
959            ),
960            file_bytes: artifact.snapshot.file_bytes,
961        },
962    })
963}
964
965fn retrieval_proof_transport(
966    artifact: &ExactRetrievalProofArtifact,
967    state: &ServerState,
968    request_id: &str,
969) -> Result<RetrievalProofV1, ApiError> {
970    let encoded = artifact
971        .proof
972        .to_bytes()
973        .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
974    if encoded.len() > state.limits.proof_bytes
975        || artifact.snapshot.file_bytes > state.limits.witness_bytes
976    {
977        return Err(ApiError::result_too_large(request_id));
978    }
979    let anchor = artifact.proof.anchor();
980    let snapshot_digest = encode_hex(&anchor.snapshot_digest);
981    Ok(RetrievalProofV1 {
982        encoding: "base64".to_owned(),
983        data: BASE64.encode(encoded),
984        proof_digest: encode_hex(&artifact.proof.proof_digest()),
985        anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
986        checkpoint_sequence: anchor.checkpoint_sequence,
987        checkpoint_digest: anchor
988            .checkpoint_digest
989            .as_ref()
990            .map(|digest| encode_hex(digest)),
991        snapshot_digest: snapshot_digest.clone(),
992        witness: WitnessV1 {
993            path: format!(
994                "/v1/witnesses/{}/{}",
995                anchor.checkpoint_sequence, snapshot_digest
996            ),
997            file_bytes: artifact.snapshot.file_bytes,
998        },
999    })
1000}
1001
1002fn lexical_retrieval_proof_transport(
1003    artifact: &LexicalRetrievalProofArtifact,
1004    state: &ServerState,
1005    request_id: &str,
1006) -> Result<RetrievalProofV1, ApiError> {
1007    retrieval_proof_transport_parts(
1008        artifact.proof.to_bytes(),
1009        artifact.proof.proof_digest(),
1010        artifact.proof.anchor_digest(),
1011        artifact.proof.anchor(),
1012        artifact.snapshot.file_bytes,
1013        state,
1014        request_id,
1015    )
1016}
1017
1018fn hybrid_retrieval_proof_transport(
1019    artifact: &HybridRetrievalProofArtifact,
1020    state: &ServerState,
1021    request_id: &str,
1022) -> Result<RetrievalProofV1, ApiError> {
1023    retrieval_proof_transport_parts(
1024        artifact.proof.to_bytes(),
1025        artifact.proof.proof_digest(),
1026        artifact.proof.anchor_digest(),
1027        artifact.proof.anchor(),
1028        artifact.snapshot.file_bytes,
1029        state,
1030        request_id,
1031    )
1032}
1033
1034#[allow(clippy::too_many_arguments)]
1035fn retrieval_proof_transport_parts(
1036    encoded: Result<Vec<u8>, hyphae_engine::RetrievalProofError>,
1037    proof_digest: [u8; 32],
1038    anchor_digest: [u8; 32],
1039    anchor: &hyphae_engine::RetrievalProofAnchor,
1040    witness_bytes: u64,
1041    state: &ServerState,
1042    request_id: &str,
1043) -> Result<RetrievalProofV1, ApiError> {
1044    let encoded = encoded
1045        .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
1046    if encoded.len() > state.limits.proof_bytes || witness_bytes > state.limits.witness_bytes {
1047        return Err(ApiError::result_too_large(request_id));
1048    }
1049    let snapshot_digest = encode_hex(&anchor.snapshot_digest);
1050    Ok(RetrievalProofV1 {
1051        encoding: "base64".to_owned(),
1052        data: BASE64.encode(encoded),
1053        proof_digest: encode_hex(&proof_digest),
1054        anchor_digest: encode_hex(&anchor_digest),
1055        checkpoint_sequence: anchor.checkpoint_sequence,
1056        checkpoint_digest: anchor
1057            .checkpoint_digest
1058            .as_ref()
1059            .map(|digest| encode_hex(digest)),
1060        snapshot_digest: snapshot_digest.clone(),
1061        witness: WitnessV1 {
1062            path: format!(
1063                "/v1/witnesses/{}/{}",
1064                anchor.checkpoint_sequence, snapshot_digest
1065            ),
1066            file_bytes: witness_bytes,
1067        },
1068    })
1069}
1070
1071fn exact_outcome_transport(outcome: &ExactRetrievalOutcome) -> ExactRetrievalOutcomeV1 {
1072    match outcome {
1073        ExactRetrievalOutcome::Matches {
1074            matches,
1075            scanned_candidates,
1076        } => ExactRetrievalOutcomeV1::Matches {
1077            matches: matches
1078                .iter()
1079                .map(|matched| ExactRetrievalMatchV1 {
1080                    key_hex: encode_hex(&matched.key),
1081                    score_nanos: matched.score_nanos,
1082                })
1083                .collect(),
1084            scanned_candidates: *scanned_candidates,
1085        },
1086        ExactRetrievalOutcome::Abstained(abstention) => ExactRetrievalOutcomeV1::Abstained {
1087            abstention: ExactAbstentionV1 {
1088                reason: match abstention.reason {
1089                    ExactAbstentionReason::NoCandidates => ExactAbstentionReasonV1::NoCandidates,
1090                    ExactAbstentionReason::BelowThreshold => {
1091                        ExactAbstentionReasonV1::BelowThreshold
1092                    }
1093                    ExactAbstentionReason::Ambiguous => ExactAbstentionReasonV1::Ambiguous,
1094                },
1095                best_score_nanos: abstention.best_score_nanos,
1096                runner_up_score_nanos: abstention.runner_up_score_nanos,
1097                scanned_candidates: abstention.scanned_candidates,
1098            },
1099        },
1100    }
1101}
1102
1103fn lexical_outcome_transport(outcome: &LexicalOutcome) -> LexicalRetrievalOutcomeV1 {
1104    match outcome {
1105        LexicalOutcome::Matches {
1106            matches,
1107            scanned_documents,
1108            matched_documents,
1109            query_tokens,
1110        } => LexicalRetrievalOutcomeV1::Matches {
1111            matches: matches
1112                .iter()
1113                .map(|matched| LexicalRetrievalMatchV1 {
1114                    key_hex: encode_hex(&matched.key),
1115                    score_nanos: matched.score_nanos,
1116                    terms: matched
1117                        .terms
1118                        .iter()
1119                        .map(|term| LexicalTermContributionV1 {
1120                            token: term.token.clone(),
1121                            document_frequency: term.document_frequency,
1122                            score_nanos: term.score_nanos,
1123                            fields: term
1124                                .fields
1125                                .iter()
1126                                .map(|field| LexicalFieldContributionV1 {
1127                                    path: field.path.segments().to_vec(),
1128                                    term_frequency: field.term_frequency,
1129                                    field_length: field.field_length,
1130                                })
1131                                .collect(),
1132                        })
1133                        .collect(),
1134                })
1135                .collect(),
1136            scanned_documents: *scanned_documents,
1137            matched_documents: *matched_documents,
1138            query_tokens: query_tokens.clone(),
1139        },
1140        LexicalOutcome::Abstained(abstention) => LexicalRetrievalOutcomeV1::Abstained {
1141            abstention: LexicalAbstentionV1 {
1142                reason: match abstention.reason {
1143                    LexicalAbstentionReason::NoCandidates => {
1144                        LexicalAbstentionReasonV1::NoCandidates
1145                    }
1146                },
1147                scanned_documents: abstention.scanned_documents,
1148                query_tokens: abstention.query_tokens.clone(),
1149            },
1150        },
1151    }
1152}
1153
1154fn hybrid_outcome_transport(outcome: &HybridOutcome) -> HybridRetrievalOutcomeV1 {
1155    match outcome {
1156        HybridOutcome::Matches {
1157            matches,
1158            lexical_absence,
1159            vector_absence,
1160        } => HybridRetrievalOutcomeV1::Matches {
1161            matches: matches
1162                .iter()
1163                .map(|matched| HybridRetrievalMatchV1 {
1164                    key_hex: encode_hex(&matched.key),
1165                    explanation: HybridExplanationV1 {
1166                        lexical_rank: matched.explanation.lexical_rank,
1167                        lexical_score_nanos: matched.explanation.lexical_score_nanos,
1168                        vector_rank: matched.explanation.vector_rank,
1169                        vector_score_nanos: matched.explanation.vector_score_nanos,
1170                        lexical_contribution: matched.explanation.lexical_contribution,
1171                        vector_contribution: matched.explanation.vector_contribution,
1172                        fusion_score: matched.explanation.fusion_score,
1173                        final_rank: matched.explanation.final_rank,
1174                    },
1175                })
1176                .collect(),
1177            lexical_absence: lexical_absence.map(hybrid_absence_transport),
1178            vector_absence: vector_absence.map(hybrid_absence_transport),
1179        },
1180        HybridOutcome::Abstained(abstention) => HybridRetrievalOutcomeV1::Abstained {
1181            abstention: HybridAbstentionV1 {
1182                lexical: hybrid_absence_transport(abstention.lexical),
1183                vector: hybrid_absence_transport(abstention.vector),
1184            },
1185        },
1186    }
1187}
1188
1189fn hybrid_absence_transport(absence: HybridBranchAbsence) -> HybridBranchAbsenceV1 {
1190    match absence {
1191        HybridBranchAbsence::LexicalNoCandidates => HybridBranchAbsenceV1::LexicalNoCandidates,
1192        HybridBranchAbsence::VectorNoCandidates => HybridBranchAbsenceV1::VectorNoCandidates,
1193        HybridBranchAbsence::VectorBelowThreshold => HybridBranchAbsenceV1::VectorBelowThreshold,
1194        HybridBranchAbsence::VectorAmbiguous => HybridBranchAbsenceV1::VectorAmbiguous,
1195    }
1196}
1197
1198fn receipt(outcome: AppendOutcome) -> CommitReceiptV1 {
1199    let (status, receipt) = match outcome {
1200        AppendOutcome::Committed(receipt) => ("committed", receipt),
1201        AppendOutcome::Existing(receipt) => ("existing", receipt),
1202    };
1203    CommitReceiptV1 {
1204        status: status.to_owned(),
1205        transaction_id: receipt.transaction_id.to_string(),
1206        commit_sequence: receipt.commit_sequence,
1207        commit_digest: encode_hex(&receipt.commit_digest),
1208        transaction_digest: encode_hex(&receipt.transaction_digest),
1209    }
1210}
1211
1212fn bounded_json<T: Serialize>(
1213    value: &T,
1214    state: &ServerState,
1215    request_id: &str,
1216) -> Result<Response, ApiError> {
1217    let encoded = serde_json::to_vec(value).map_err(|_| ApiError::internal(request_id))?;
1218    if encoded.len() > state.limits.response_bytes {
1219        return Err(ApiError::result_too_large(request_id));
1220    }
1221    Response::builder()
1222        .status(StatusCode::OK)
1223        .header(header::CONTENT_TYPE, "application/json")
1224        .header(header::CONTENT_LENGTH, encoded.len())
1225        .body(Body::from(encoded))
1226        .map_err(|_| ApiError::internal(request_id))
1227}
1228
1229fn parse_witness_path(path: &str) -> Option<(u64, [u8; 32])> {
1230    let suffix = path.strip_prefix("/v1/witnesses/")?;
1231    let mut components = suffix.split('/');
1232    let sequence = components.next()?.parse().ok()?;
1233    let digest = components.next()?;
1234    if components.next().is_some()
1235        || digest.len() != 64
1236        || !digest
1237            .bytes()
1238            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1239    {
1240        return None;
1241    }
1242    let decoded = decode_key_hex(digest).ok()?;
1243    decoded.try_into().ok().map(|digest| (sequence, digest))
1244}
1245
1246fn request_id(request: &Request) -> String {
1247    request
1248        .extensions()
1249        .get::<RequestId>()
1250        .map_or_else(|| Uuid::now_v7().to_string(), |value| value.0.clone())
1251}
1252
1253fn busy(request_id: &str) -> ApiError {
1254    ApiError::new(
1255        StatusCode::TOO_MANY_REQUESTS,
1256        "busy",
1257        "concurrent operation admission limit reached",
1258        request_id,
1259    )
1260}
1261
1262fn not_found(request_id: &str) -> ApiError {
1263    ApiError::new(
1264        StatusCode::NOT_FOUND,
1265        "not_found",
1266        "requested version 1 resource does not exist",
1267        request_id,
1268    )
1269}
1270
1271async fn route_not_found(Extension(request_id): Extension<RequestId>) -> ApiError {
1272    not_found(&request_id.0)
1273}
1274
1275async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> ApiError {
1276    ApiError::new(
1277        StatusCode::METHOD_NOT_ALLOWED,
1278        "method_not_allowed",
1279        "HTTP method is not defined for this version 1 route",
1280        request_id.0,
1281    )
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use std::{error::Error, fs, net::Ipv4Addr, path::PathBuf, sync::Arc, time::Duration};
1287
1288    use axum::{body::Body, http::Request};
1289    use serde_json::Value;
1290    use tokio::{
1291        io::{AsyncReadExt as _, AsyncWriteExt as _},
1292        net::TcpStream,
1293        sync::oneshot,
1294    };
1295    use tokio_util::io::ReaderStream;
1296    use tower::ServiceExt;
1297
1298    use hyphae_engine::EngineError;
1299    use hyphae_storage::{AppendOutcome, CommitReceipt, MaterializedIndexError, StorageError};
1300
1301    use super::{HyphaeServer, ServerConfig, StatusCode, body, capture_write_outcome};
1302    use crate::{BearerToken, ServerConfigError};
1303
1304    struct TestDirectory {
1305        path: PathBuf,
1306    }
1307
1308    impl TestDirectory {
1309        fn create(name: &str) -> Result<Self, Box<dyn Error>> {
1310            let path = std::env::temp_dir().join(format!(
1311                "hyphae-server-{name}-{}-{}",
1312                std::process::id(),
1313                uuid::Uuid::now_v7()
1314            ));
1315            fs::create_dir_all(&path)?;
1316            Ok(Self { path })
1317        }
1318    }
1319
1320    impl Drop for TestDirectory {
1321        fn drop(&mut self) {
1322            let _ignored = fs::remove_dir_all(&self.path);
1323        }
1324    }
1325
1326    #[test]
1327    fn remote_bind_is_rejected_before_socket_bind() -> Result<(), Box<dyn Error>> {
1328        let temporary = TestDirectory::create("remote-rejected")?;
1329        let mut config = ServerConfig::new(&temporary.path);
1330        config.bind = (Ipv4Addr::UNSPECIFIED, 8_787).into();
1331        assert!(matches!(
1332            HyphaeServer::open(config),
1333            Err(crate::ServerError::Configuration(
1334                ServerConfigError::RemoteBindRequiresAuthentication { .. }
1335            ))
1336        ));
1337        Ok(())
1338    }
1339
1340    #[test]
1341    fn bearer_tokens_require_visible_header_safe_entropy() {
1342        assert!(BearerToken::new("short").is_err());
1343        assert!(BearerToken::new("0123456789abcdef0123456789abcde\n").is_err());
1344        assert!(BearerToken::new("0123456789abcdef0123456789abcdef").is_ok());
1345    }
1346
1347    #[test]
1348    fn durable_unmaterialized_commit_keeps_its_public_receipt() -> Result<(), Box<dyn Error>> {
1349        let receipt = CommitReceipt {
1350            transaction_id: uuid::Uuid::now_v7(),
1351            commit_sequence: 9,
1352            commit_digest: [7; 32],
1353            transaction_digest: [8; 32],
1354        };
1355        let outcome = capture_write_outcome(Err(EngineError::Storage(
1356            StorageError::CommittedButNotIndexed {
1357                receipt,
1358                source: Box::new(MaterializedIndexError::MalformedCheckpoint),
1359            },
1360        )))?;
1361        assert!(outcome.requires_recovery);
1362        assert!(matches!(
1363            outcome.append,
1364            AppendOutcome::Committed(actual) if actual == receipt
1365        ));
1366        Ok(())
1367    }
1368
1369    #[tokio::test]
1370    async fn authenticated_put_get_and_witness_are_contract_shaped() -> Result<(), Box<dyn Error>> {
1371        let temporary = TestDirectory::create("authenticated-flow")?;
1372        let secret = "correct-hyphae-token-material-0001";
1373        let mut config = ServerConfig::new(&temporary.path);
1374        config.bearer_token = Some(BearerToken::new(secret)?);
1375        let app = HyphaeServer::open(config)?.test_router();
1376
1377        let unauthorized = app
1378            .clone()
1379            .oneshot(json_request("/v1/kv/put", r#"{"records":[]}"#, None)?)
1380            .await?;
1381        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
1382        assert!(unauthorized.headers().contains_key("x-request-id"));
1383
1384        let wrong = app
1385            .clone()
1386            .oneshot(json_request(
1387                "/v1/kv/put",
1388                r#"{"records":[]}"#,
1389                Some("incorrect-hyphae-token-material-001"),
1390            )?)
1391            .await?;
1392        assert_error(wrong, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1393
1394        let duplicate_header = app
1395            .clone()
1396            .oneshot(
1397                Request::builder()
1398                    .method("POST")
1399                    .uri("/v1/query")
1400                    .header("content-type", "application/json")
1401                    .header("authorization", format!("Bearer {secret}"))
1402                    .header("authorization", format!("Bearer {secret}"))
1403                    .body(Body::from(r#"{"limit":1}"#))?,
1404            )
1405            .await?;
1406        assert_error(duplicate_header, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1407
1408        let put = app
1409            .clone()
1410            .oneshot(json_request(
1411                "/v1/kv/put",
1412                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1413                Some(secret),
1414            )?)
1415            .await?;
1416        assert_eq!(put.status(), StatusCode::OK);
1417        let put: Value = serde_json::from_slice(&response_bytes(put).await?)?;
1418        assert_eq!(put["status"], "committed");
1419
1420        let retry = app
1421            .clone()
1422            .oneshot(json_request(
1423                "/v1/kv/put",
1424                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1425                Some(secret),
1426            )?)
1427            .await?;
1428        assert_eq!(retry.status(), StatusCode::OK);
1429        let retry: Value = serde_json::from_slice(&response_bytes(retry).await?)?;
1430        assert_eq!(retry["status"], "existing");
1431
1432        let conflict = app
1433            .clone()
1434            .oneshot(json_request(
1435                "/v1/kv/put",
1436                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":8}}]}"#,
1437                Some(secret),
1438            )?)
1439            .await?;
1440        assert_error(conflict, StatusCode::CONFLICT, "idempotency_conflict").await?;
1441
1442        let get = app
1443            .clone()
1444            .oneshot(json_request(
1445                "/v1/kv/get",
1446                r#"{"key_hex":"61"}"#,
1447                Some(secret),
1448            )?)
1449            .await?;
1450        assert_eq!(get.status(), StatusCode::OK);
1451        let get: Value = serde_json::from_slice(&response_bytes(get).await?)?;
1452        assert_eq!(get["found"], true);
1453        assert_eq!(get["record"]["value"]["score"], 7);
1454        assert_eq!(get["proof"]["encoding"], "base64");
1455        let witness_path = get["proof"]["witness"]["path"]
1456            .as_str()
1457            .ok_or("missing witness path")?;
1458
1459        let witness = app
1460            .clone()
1461            .oneshot(
1462                Request::builder()
1463                    .uri(witness_path)
1464                    .header("authorization", format!("Bearer {secret}"))
1465                    .body(Body::empty())?,
1466            )
1467            .await?;
1468        assert_eq!(witness.status(), StatusCode::OK);
1469        assert!(witness.headers().contains_key("digest"));
1470        assert!(response_bytes(witness).await?.starts_with(b"HYSNAP01"));
1471
1472        let query = app
1473            .oneshot(json_request("/v1/query", r#"{"limit":10}"#, Some(secret))?)
1474            .await?;
1475        assert_eq!(query.status(), StatusCode::OK);
1476        let query: Value = serde_json::from_slice(&response_bytes(query).await?)?;
1477        assert_eq!(query["rows"].as_array().map(Vec::len), Some(1));
1478        assert_eq!(query["proof"]["encoding"], "base64");
1479        Ok(())
1480    }
1481
1482    #[tokio::test]
1483    async fn public_routes_and_limit_failures_never_emit_framework_text()
1484    -> Result<(), Box<dyn Error>> {
1485        let temporary = TestDirectory::create("limits")?;
1486        let mut config = ServerConfig::new(&temporary.path);
1487        config.limits.request_body_bytes = 1_024;
1488        config.limits.batch_items = 1;
1489        let app = HyphaeServer::open(config)?.test_router();
1490
1491        let capabilities = app
1492            .clone()
1493            .oneshot(
1494                Request::builder()
1495                    .uri("/v1/capabilities")
1496                    .body(Body::empty())?,
1497            )
1498            .await?;
1499        assert_eq!(capabilities.status(), StatusCode::OK);
1500        let capabilities: Value = serde_json::from_slice(&response_bytes(capabilities).await?)?;
1501        assert_eq!(capabilities["api_version"], "v1");
1502        assert_eq!(capabilities["limits"]["batch_items"], 1);
1503
1504        let too_many = app
1505            .clone()
1506            .oneshot(json_request(
1507                "/v1/kv/delete",
1508                r#"{"keys_hex":["61","62"]}"#,
1509                None,
1510            )?)
1511            .await?;
1512        assert_error(too_many, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1513
1514        let unsupported = app
1515            .clone()
1516            .oneshot(
1517                Request::builder()
1518                    .method("POST")
1519                    .uri("/v1/query")
1520                    .header("content-type", "text/plain")
1521                    .body(Body::from("{}"))?,
1522            )
1523            .await?;
1524        assert_error(
1525            unsupported,
1526            StatusCode::UNSUPPORTED_MEDIA_TYPE,
1527            "unsupported_media_type",
1528        )
1529        .await?;
1530
1531        let oversized = app
1532            .clone()
1533            .oneshot(json_request(
1534                "/v1/query",
1535                &format!(r#"{{"limit":1,"ignored":"{}"}}"#, "x".repeat(2_000)),
1536                None,
1537            )?)
1538            .await?;
1539        assert_error(
1540            oversized,
1541            StatusCode::PAYLOAD_TOO_LARGE,
1542            "payload_too_large",
1543        )
1544        .await?;
1545
1546        let missing = app
1547            .clone()
1548            .oneshot(Request::builder().uri("/v1/unknown").body(Body::empty())?)
1549            .await?;
1550        assert_error(missing, StatusCode::NOT_FOUND, "not_found").await?;
1551
1552        let wrong_method = app
1553            .oneshot(
1554                Request::builder()
1555                    .method("DELETE")
1556                    .uri("/v1/health/live")
1557                    .body(Body::empty())?,
1558            )
1559            .await?;
1560        assert_error(
1561            wrong_method,
1562            StatusCode::METHOD_NOT_ALLOWED,
1563            "method_not_allowed",
1564        )
1565        .await?;
1566        Ok(())
1567    }
1568
1569    #[tokio::test]
1570    async fn vector_lexical_and_hybrid_routes_return_proof_bearing_results()
1571    -> Result<(), Box<dyn Error>> {
1572        let temporary = TestDirectory::create("retrieval-flow")?;
1573        let app = HyphaeServer::open(ServerConfig::new(&temporary.path))?.test_router();
1574
1575        for (path, payload) in [
1576            (
1577                "/v1/kv/put",
1578                r#"{"records":[{"key_hex":"616c706861","value":{"title":"Durable memory","body":"offline agent memory"}},{"key_hex":"62657461","value":{"title":"Fast search","body":"exact vector retrieval"}}]}"#,
1579            ),
1580            (
1581                "/v1/lexical-indexes/define",
1582                r#"{"lexical_index":{"name":"content","fields":[{"path":["title"],"weight_micros":2000000},{"path":["body"],"weight_micros":1000000}]}}"#,
1583            ),
1584            (
1585                "/v1/vector-spaces/define",
1586                r#"{"vector_space":{"name":"semantic","dimension":2,"metric":"cosine_q15_nanos"}}"#,
1587            ),
1588            (
1589                "/v1/vectors/put",
1590                r#"{"vector_space":"semantic","vectors":[{"key_hex":"616c706861","values":[32767,0]},{"key_hex":"62657461","values":[0,32767]}]}"#,
1591            ),
1592        ] {
1593            let response = app
1594                .clone()
1595                .oneshot(json_request(path, payload, None)?)
1596                .await?;
1597            assert_eq!(response.status(), StatusCode::OK, "{path}");
1598        }
1599
1600        let exact = app
1601            .clone()
1602            .oneshot(json_request(
1603                "/v1/retrieve/exact",
1604                r#"{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1605                None,
1606            )?)
1607            .await?;
1608        assert_eq!(exact.status(), StatusCode::OK);
1609        let exact: Value = serde_json::from_slice(&response_bytes(exact).await?)?;
1610        assert_eq!(exact["outcome"]["matches"][0]["key_hex"], "616c706861");
1611        assert_eq!(exact["proof"]["encoding"], "base64");
1612
1613        let lexical = app
1614            .clone()
1615            .oneshot(json_request(
1616                "/v1/retrieve/lexical",
1617                r#"{"lexical_index":"content","query":"durable memory","limit":2}"#,
1618                None,
1619            )?)
1620            .await?;
1621        assert_eq!(lexical.status(), StatusCode::OK);
1622        let lexical: Value = serde_json::from_slice(&response_bytes(lexical).await?)?;
1623        assert_eq!(lexical["outcome"]["matches"][0]["key_hex"], "616c706861");
1624        assert_eq!(lexical["proof"]["encoding"], "base64");
1625
1626        let hybrid = app
1627            .clone()
1628            .oneshot(json_request(
1629                "/v1/retrieve/hybrid",
1630                r#"{"lexical":{"lexical_index":"content","query":"durable memory","limit":2},"vector":{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0},"lexical_weight":1,"vector_weight":1,"limit":2}"#,
1631                None,
1632            )?)
1633            .await?;
1634        assert_eq!(hybrid.status(), StatusCode::OK);
1635        let hybrid: Value = serde_json::from_slice(&response_bytes(hybrid).await?)?;
1636        assert_eq!(hybrid["outcome"]["matches"][0]["key_hex"], "616c706861");
1637        assert_eq!(
1638            hybrid["outcome"]["matches"][0]["explanation"]["final_rank"],
1639            1
1640        );
1641        assert_eq!(hybrid["proof"]["encoding"], "base64");
1642
1643        let wrong_dimension = app
1644            .clone()
1645            .oneshot(json_request(
1646                "/v1/retrieve/exact",
1647                r#"{"vector_space":"semantic","query":[32767],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1648                None,
1649            )?)
1650            .await?;
1651        assert_error(wrong_dimension, StatusCode::BAD_REQUEST, "invalid_request").await?;
1652
1653        let empty_query = app
1654            .oneshot(json_request(
1655                "/v1/retrieve/lexical",
1656                r#"{"lexical_index":"content","query":"---","limit":2}"#,
1657                None,
1658            )?)
1659            .await?;
1660        assert_error(empty_query, StatusCode::BAD_REQUEST, "invalid_request").await?;
1661        Ok(())
1662    }
1663
1664    #[tokio::test]
1665    async fn shape_proof_and_admission_limits_fail_without_partial_results()
1666    -> Result<(), Box<dyn Error>> {
1667        let temporary = TestDirectory::create("bounded-work")?;
1668        let mut config = ServerConfig::new(&temporary.path);
1669        config.limits.json_depth = 3;
1670        config.limits.concurrent_operations = 1;
1671        config.limits.proof_bytes = 128;
1672        let server = HyphaeServer::open(config)?;
1673        let app = server.test_router();
1674
1675        let too_deep = app
1676            .clone()
1677            .oneshot(json_request(
1678                "/v1/query",
1679                r#"{"filter":{"op":"not","filter":{"op":"not","filter":{"op":"not","filter":{"op":"match_all"}}}},"limit":1}"#,
1680                None,
1681            )?)
1682            .await?;
1683        assert_error(too_deep, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1684
1685        let put = app
1686            .clone()
1687            .oneshot(json_request(
1688                "/v1/kv/put",
1689                r#"{"records":[{"key_hex":"61","value":1}]}"#,
1690                None,
1691            )?)
1692            .await?;
1693        assert_eq!(put.status(), StatusCode::OK);
1694
1695        let proof_too_large = app
1696            .clone()
1697            .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1698            .await?;
1699        assert_error(
1700            proof_too_large,
1701            StatusCode::PAYLOAD_TOO_LARGE,
1702            "result_too_large",
1703        )
1704        .await?;
1705
1706        let permit = Arc::clone(&server.state.admission).try_acquire_owned()?;
1707        let busy = app
1708            .clone()
1709            .oneshot(json_request("/v1/query", r#"{"limit":1}"#, None)?)
1710            .await?;
1711        drop(permit);
1712        assert_error(busy, StatusCode::TOO_MANY_REQUESTS, "busy").await?;
1713
1714        server
1715            .state
1716            .ready
1717            .store(false, std::sync::atomic::Ordering::Release);
1718        let unavailable = app
1719            .oneshot(
1720                Request::builder()
1721                    .uri("/v1/health/ready")
1722                    .body(Body::empty())?,
1723            )
1724            .await?;
1725        assert_error(unavailable, StatusCode::SERVICE_UNAVAILABLE, "unavailable").await?;
1726        Ok(())
1727    }
1728
1729    #[tokio::test]
1730    async fn stalled_json_body_times_out_before_any_operation_starts() -> Result<(), Box<dyn Error>>
1731    {
1732        let temporary = TestDirectory::create("body-timeout")?;
1733        let mut config = ServerConfig::new(&temporary.path);
1734        config.limits.request_body_timeout = Duration::from_millis(5);
1735        let app = HyphaeServer::open(config)?.test_router();
1736        let (_writer, reader) = tokio::io::duplex(1);
1737        let response = app
1738            .oneshot(
1739                Request::builder()
1740                    .method("POST")
1741                    .uri("/v1/query")
1742                    .header("content-type", "application/json")
1743                    .body(Body::from_stream(ReaderStream::new(reader)))?,
1744            )
1745            .await?;
1746        assert_error(response, StatusCode::REQUEST_TIMEOUT, "timeout").await?;
1747        Ok(())
1748    }
1749
1750    #[tokio::test]
1751    async fn bound_server_stops_on_graceful_shutdown() -> Result<(), Box<dyn Error>> {
1752        let temporary = TestDirectory::create("graceful")?;
1753        let mut config = ServerConfig::new(&temporary.path);
1754        config.bind.set_port(0);
1755        let bound = HyphaeServer::open(config)?.bind().await?;
1756        let local_addr = bound.local_addr();
1757        assert_ne!(local_addr.port(), 0);
1758        let (send, receive) = oneshot::channel::<()>();
1759        let serving = tokio::spawn(bound.run_with_shutdown(async move {
1760            let _ignored = receive.await;
1761        }));
1762        let mut connection = TcpStream::connect(local_addr).await?;
1763        connection
1764            .write_all(
1765                b"GET /v1/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1766            )
1767            .await?;
1768        let mut response = Vec::new();
1769        connection.read_to_end(&mut response).await?;
1770        assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n"));
1771        assert!(response.ends_with(br#"{"status":"live"}"#));
1772        let _ignored = send.send(());
1773        serving.await??;
1774        Ok(())
1775    }
1776
1777    fn json_request(
1778        uri: &str,
1779        body: &str,
1780        bearer: Option<&str>,
1781    ) -> Result<Request<Body>, axum::http::Error> {
1782        let mut request = Request::builder()
1783            .method("POST")
1784            .uri(uri)
1785            .header("content-type", "application/json");
1786        if let Some(bearer) = bearer {
1787            request = request.header("authorization", format!("Bearer {bearer}"));
1788        }
1789        request.body(Body::from(body.to_owned()))
1790    }
1791
1792    async fn response_bytes(
1793        response: axum::response::Response,
1794    ) -> Result<axum::body::Bytes, Box<dyn Error>> {
1795        Ok(body::to_bytes(response.into_body(), 64 * 1024 * 1024).await?)
1796    }
1797
1798    async fn assert_error(
1799        response: axum::response::Response,
1800        status: StatusCode,
1801        code: &str,
1802    ) -> Result<(), Box<dyn Error>> {
1803        assert_eq!(response.status(), status);
1804        let header_request_id = response
1805            .headers()
1806            .get("x-request-id")
1807            .and_then(|value| value.to_str().ok())
1808            .ok_or("missing request ID header")?
1809            .to_owned();
1810        let value: Value = serde_json::from_slice(&response_bytes(response).await?)?;
1811        assert_eq!(value["code"], code);
1812        assert_eq!(value["request_id"], header_request_id);
1813        Ok(())
1814    }
1815}