Skip to main content

graph_storage_sdk/
plugin_api.rs

1//! The three plugin contracts of the graph-storage gateway.
2//!
3//! The gear is a stateless gateway over a pluggable store: every byte it
4//! serves comes from a [`GraphStoreV1`] implementation behind the port, and
5//! the built-in `PostgreSQL` store is registered exactly as an external plugin
6//! would be. `GraphEngineV1` serves traversal expansion;
7//! `EmbeddingProviderV1` turns text into vectors.
8//!
9//! An implementation that cannot provide an obligation declares the
10//! corresponding capability absent and returns `Unsupported` from the
11//! affected method. It never implements a weaker version — a silently
12//! weakened guarantee is worse than an absent capability, because the gear
13//! can route around the second and not the first.
14
15use async_trait::async_trait;
16use thiserror::Error;
17use tokio_util::sync::CancellationToken;
18use toolkit_security::AccessScope;
19
20use crate::models::{
21    ComponentReadiness, DeleteOutcome, DeleteRequest, Direction, EdgeKey, EdgeRef, EdgeView,
22    EmbeddingSpaceId, EngineCapabilities, GraphRevision, GtsTypeId, HopBudget, IngestOutcome,
23    IngestRequest, ItemError, LabelAssignment, LabelFilter, LabelId, LabelRecord, LabelSpec,
24    NodeId, NodeKey, NodeRow, NodeView, Page, ProjectionRequest, ReadSnapshot, RegisteredType,
25    RemainingBudget, RevisionOutcome, SearchRequest, SearchResponse, SourceNamespaceOwner,
26    StoreCapabilities, Subject, TenantId, TopologyPage, TopologyRequest, TruncationReason,
27    TypeIdSet, TypeQuery, TypeRecord, TypeRegistration, TypeRegistrationOptions,
28};
29
30/// Per-call context. The compiled scope is mandatory, not optional:
31/// authorization has to reach inside the statements (a search arm applies it
32/// before ranking and LIMIT), so it cannot be a filter the gear applies to
33/// whatever the plugin returns.
34///
35/// `scope` living here rather than in a per-method argument is what makes the
36/// request-level decline possible: an implementation inspects the compiled
37/// scope it is about to serve and may answer `ScopeUnservable` instead of a
38/// result, which the gateway resolves by falling back. It is a routing
39/// signal, not a failure, and it never reaches the caller.
40pub struct StoreCtx<'a> {
41    pub tenant: TenantId,
42    pub scope: &'a AccessScope,
43    /// The acting subject, stamped onto the audit envelope of every element a
44    /// write in this call creates, updates or tombstones (`fr-audit-envelope`).
45    pub subject: Subject,
46    /// Present when the call participates in a compound read that must
47    /// observe one graph state (Read Consistency Contract).
48    pub snapshot: Option<&'a ReadSnapshot>,
49    /// What is left of the operation's absolute deadline.
50    pub budget: RemainingBudget,
51    pub cancel: CancellationToken,
52}
53
54/// Store-side failure vocabulary. The gear normalizes these into canonical
55/// errors before they cross the public boundary; no vendor text survives.
56#[derive(Debug, Error)]
57#[non_exhaustive]
58pub enum GraphStoreError {
59    /// Request-level decline: this implementation cannot serve the compiled
60    /// scope. Routed by the gateway, never surfaced to the caller directly.
61    #[error("scope unservable: {reason}")]
62    ScopeUnservable { reason: String },
63    /// The capability is declared absent for this store.
64    #[error("unsupported: {what}")]
65    Unsupported { what: &'static str },
66    /// Per-item validation failures; the batch committed nothing.
67    #[error("{} item(s) failed validation", items.len())]
68    Validation { items: Vec<ItemError> },
69    /// Same-key different-type ingest, expected-version mismatch, or an
70    /// equal-generation replacement with different content.
71    #[error("conflict: {reason}")]
72    Conflict { reason: String },
73    /// Serialization failure under concurrent ingest; retry unchanged.
74    #[error("serialization failure")]
75    Serialization,
76    /// Older source generation for a scope; drop the stale run.
77    #[error("stale generation: recorded {recorded}, offered {offered}")]
78    StaleGeneration { recorded: i64, offered: i64 },
79    /// Idempotency key reused with a different request.
80    #[error("idempotency key reused with a different request")]
81    IdempotencyMismatch,
82    /// Receipt expired (or from a previous source epoch); reconcile first.
83    #[error("idempotency receipt expired")]
84    IdempotencyExpired,
85    /// Unauthorized or unknown resource — indistinguishable by contract.
86    #[error("not found")]
87    NotFound,
88    /// A documented hard bound was exceeded.
89    #[error("limit exceeded: {what}")]
90    LimitExceeded { what: String },
91    /// A write under a source namespace bound to another producer principal.
92    ///
93    /// Deliberately **not** `NotFound`: everywhere else a denial is
94    /// indistinguishable from absence (anti-enumeration), but here the caller
95    /// named a namespace whose owner is a fact about the tenant, not about
96    /// them, and telling them it does not exist would send them to create it.
97    /// DESIGN § Error Model maps this to `permission_denied` /
98    /// `SOURCE_NAMESPACE_FORBIDDEN`, "never retry; request ownership
99    /// transfer".
100    #[error("source namespace `{namespace}` is owned by another producer")]
101    SourceNamespaceForbidden { namespace: String },
102    /// The query itself is malformed — an unknown filter field, an
103    /// unparseable cursor, an ordering the store cannot serve. Distinct from
104    /// `LimitExceeded`: nothing here is about a bound, and telling a caller
105    /// "reduce the value" when they named a field that does not exist sends
106    /// them the wrong way.
107    #[error("invalid query: {what}")]
108    InvalidQuery { what: String },
109    /// Durable corruption detected; operator action.
110    #[error("store corrupt: {reason}")]
111    Corrupt { reason: String },
112    #[error("store unavailable: {reason}")]
113    Unavailable { reason: String },
114    #[error("deadline exceeded")]
115    Deadline,
116    #[error("cancelled")]
117    Cancelled,
118    /// Unexpected failure; details stay in access-controlled logs.
119    #[error("internal store error: {0}")]
120    Internal(String),
121}
122
123/// The store plugin contract (`cpt-cf-graph-storage-contract-graph-store-plugin`).
124///
125/// Five obligations are carried by specific methods and asserted by the
126/// conformance suite against both the built-in store and the in-memory fake:
127/// batch atomicity (`ingest`), single-writer serialization per scope identity
128/// (`ingest` + `replace_scope`), monotonic generation fencing
129/// (`replace_scope.generation`), no node removed while a live edge references
130/// it (`soft_delete`), and one snapshot across every arm of one read
131/// (`begin_read`).
132#[async_trait]
133pub trait GraphStoreV1: Send + Sync + 'static {
134    /// What this store provides. Anything absent here is answered
135    /// `Unsupported` by the methods below, never approximated.
136    fn capabilities(&self) -> StoreCapabilities;
137
138    // --- ontology ---------------------------------------------------------
139    /// Register a batch atomically.
140    ///
141    /// `options.on_existing` decides what a changed schema under a registered
142    /// identifier means: `Reject` (the default) conflicts, `Update` admits the
143    /// change when it is admissible. A byte-identical re-registration
144    /// converges under either. `options.dry_run` computes every verdict and
145    /// writes nothing, so a caller can ask what an edit costs before making
146    /// it; a dry run therefore reports a refusal in the result rather than as
147    /// an error.
148    async fn register_types_with(
149        &self,
150        ctx: &StoreCtx<'_>,
151        batch: Vec<TypeRegistration>,
152        options: TypeRegistrationOptions,
153    ) -> Result<Vec<RegisteredType>, GraphStoreError>;
154
155    /// Register a batch under the default options, keeping only the records.
156    ///
157    /// Provided, not implemented: one code path decides admission, so the
158    /// convenience form cannot drift from the form that carries the options.
159    async fn register_types(
160        &self,
161        ctx: &StoreCtx<'_>,
162        batch: Vec<TypeRegistration>,
163    ) -> Result<Vec<TypeRecord>, GraphStoreError> {
164        let registered = self
165            .register_types_with(ctx, batch, TypeRegistrationOptions::default())
166            .await?;
167        Ok(registered.into_iter().map(|item| item.record).collect())
168    }
169    async fn get_type(
170        &self,
171        ctx: &StoreCtx<'_>,
172        id: &GtsTypeId,
173    ) -> Result<TypeRecord, GraphStoreError>;
174    async fn list_types(
175        &self,
176        ctx: &StoreCtx<'_>,
177        query: TypeQuery,
178    ) -> Result<Page<TypeRecord>, GraphStoreError>;
179    /// Probe what this store can answer for, without a tenant or a scope.
180    ///
181    /// Readiness is reached before authentication — the matrix leaves the
182    /// health endpoints available when the authorization resolver is down —
183    /// so this is the one store call that takes no `StoreCtx`. It reports the
184    /// rows only the store can answer: the database and its migrations, and
185    /// the traversal backend the server actually provides.
186    async fn probe_readiness(&self) -> Vec<ComponentReadiness>;
187
188    // --- source namespaces ------------------------------------------------
189    /// The namespaces claimed in this tenant, with the principal bound to
190    /// each. A read of the ownership boundary itself, for an operator who has
191    /// to answer "who owns this source".
192    async fn list_source_namespaces(
193        &self,
194        ctx: &StoreCtx<'_>,
195    ) -> Result<Vec<SourceNamespaceOwner>, GraphStoreError>;
196
197    /// Bind `namespace` to `owner_principal`, recording who moved it.
198    ///
199    /// The only way a namespace changes hands: writing under someone else's
200    /// namespace is refused rather than treated as a claim, so there is no
201    /// implicit transfer. The caller is authorized for ontology
202    /// administration, not merely for writing.
203    async fn transfer_source_namespace(
204        &self,
205        ctx: &StoreCtx<'_>,
206        namespace: &str,
207        owner_principal: &str,
208    ) -> Result<SourceNamespaceOwner, GraphStoreError>;
209
210    /// Resolve GTS patterns to the set of registered types they cover, so a
211    /// caller's type filter and an authorizing permission's pattern can be
212    /// intersected on one representation.
213    async fn resolve_type_set(
214        &self,
215        ctx: &StoreCtx<'_>,
216        patterns: &[String],
217    ) -> Result<TypeIdSet, GraphStoreError>;
218
219    // --- write ------------------------------------------------------------
220    /// Nodes, edges and the idempotency record commit together or not at all.
221    /// A replay of a recorded key returns `IngestOutcome { replayed: true }`
222    /// without touching state.
223    ///
224    /// `embedding` carries one entry per node of `req`, in order: the vector
225    /// to store if this request embedded, and the canonical hash of the text
226    /// it was composed from either way. A store does not compose or embed
227    /// anything — that is the coordinator's, so that ingest and query cannot
228    /// diverge.
229    async fn ingest(
230        &self,
231        ctx: &StoreCtx<'_>,
232        req: IngestRequest,
233        embedding: EmbeddingPlan,
234    ) -> Result<IngestOutcome, GraphStoreError>;
235    /// Tombstone a node with its incident edges, or a single edge.
236    async fn soft_delete(
237        &self,
238        ctx: &StoreCtx<'_>,
239        req: DeleteRequest,
240    ) -> Result<DeleteOutcome, GraphStoreError>;
241
242    // --- labels -----------------------------------------------------------
243    // Labels are deferred: no store in this release provides them and the
244    // gear routes nothing to these methods. The slots are in the contract so
245    // that labels can arrive as `StoreCapabilities::labels` turning true
246    // rather than as a new trait version, and they default to `Unsupported`
247    // so that a store which does not provide them — every store today —
248    // writes nothing for them. A store that sets the capability overrides
249    // all four.
250    async fn upsert_label(
251        &self,
252        _ctx: &StoreCtx<'_>,
253        _label: LabelSpec,
254    ) -> Result<LabelRecord, GraphStoreError> {
255        Err(GraphStoreError::Unsupported { what: "labels" })
256    }
257    async fn delete_label(
258        &self,
259        _ctx: &StoreCtx<'_>,
260        _id: LabelId,
261    ) -> Result<RevisionOutcome, GraphStoreError> {
262        Err(GraphStoreError::Unsupported { what: "labels" })
263    }
264    async fn list_labels(&self, _ctx: &StoreCtx<'_>) -> Result<Vec<LabelRecord>, GraphStoreError> {
265        Err(GraphStoreError::Unsupported { what: "labels" })
266    }
267    async fn assign_labels(
268        &self,
269        _ctx: &StoreCtx<'_>,
270        _req: LabelAssignment,
271    ) -> Result<RevisionOutcome, GraphStoreError> {
272        Err(GraphStoreError::Unsupported { what: "labels" })
273    }
274
275    // --- read -------------------------------------------------------------
276    /// Open a snapshot for a compound read. Every subsequent call carrying it
277    /// in `StoreCtx` observes one graph state.
278    async fn begin_read(&self, ctx: &StoreCtx<'_>) -> Result<ReadSnapshot, GraphStoreError>;
279    /// Close a snapshot opened by `begin_read`, releasing whatever holds it.
280    async fn end_read(&self, snapshot: ReadSnapshot) -> Result<(), GraphStoreError>;
281    async fn revision(&self, ctx: &StoreCtx<'_>) -> Result<GraphRevision, GraphStoreError>;
282    async fn get_node(
283        &self,
284        ctx: &StoreCtx<'_>,
285        key: &NodeKey,
286        adjacency_limit: u32,
287    ) -> Result<NodeView, GraphStoreError>;
288    async fn hydrate_nodes(
289        &self,
290        ctx: &StoreCtx<'_>,
291        ids: &[NodeId],
292    ) -> Result<Vec<NodeView>, GraphStoreError>;
293    /// The type of each live node named, under the caller's scope, without
294    /// reading its row: tombstoned, unknown and unauthorized ids are absent
295    /// from the answer alike, as they are from `hydrate_nodes`.
296    ///
297    /// A read that filters its output by type asks this first, so it
298    /// hydrates only the rows it will return and charges every row it reads
299    /// against its byte budget. Optional: the default answers `Unsupported`,
300    /// and the gear then hydrates and filters afterwards, which returns the
301    /// same answer at the cost of reading rows it discards.
302    ///
303    /// The order of the pairs is not part of the contract. The gear keys the
304    /// answer by id and never reads it in sequence; the built-in store
305    /// answers in whatever order the statement returns, and a store that
306    /// preserves the order asked promises nothing more by doing so.
307    async fn node_types(
308        &self,
309        _ctx: &StoreCtx<'_>,
310        _ids: &[NodeId],
311    ) -> Result<Vec<(NodeId, GtsTypeId)>, GraphStoreError> {
312        Err(GraphStoreError::Unsupported { what: "node_types" })
313    }
314    /// One edge as an element, with its payload and audit envelope
315    /// (`fr-audit-envelope`, which asks for the envelope on every node *and
316    /// edge* a read surface returns). Scoped like a node read: an edge either
317    /// of whose endpoints lies outside the caller's scope reads as absent.
318    async fn get_edge(
319        &self,
320        ctx: &StoreCtx<'_>,
321        key: &EdgeKey,
322    ) -> Result<EdgeView, GraphStoreError>;
323    /// One call, not one per arm: the scope must apply inside each arm before
324    /// UNION, ranking and LIMIT, and RRF needs each arm's ranks.
325    async fn search(
326        &self,
327        ctx: &StoreCtx<'_>,
328        req: SearchRequest,
329        vector: Option<VectorArm>,
330    ) -> Result<SearchResponse, GraphStoreError>;
331    async fn project_table(
332        &self,
333        ctx: &StoreCtx<'_>,
334        req: ProjectionRequest,
335    ) -> Result<toolkit_odata::Page<NodeRow>, GraphStoreError>;
336    /// Node keys with their type and typed edge pairs, tombstoned rows
337    /// excluded, paged. A store that cannot expose it declares the capability
338    /// absent, and analytics is unavailable in that deployment (ADR-0007).
339    async fn load_topology(
340        &self,
341        ctx: &StoreCtx<'_>,
342        req: TopologyRequest,
343    ) -> Result<TopologyPage, GraphStoreError>;
344
345    // --- keys -------------------------------------------------------------
346    /// Resolve producer keys to internal ids under the caller's scope.
347    /// Unknown and unauthorized keys are absent from the answer alike
348    /// (anti-enumeration).
349    async fn resolve_node_ids(
350        &self,
351        ctx: &StoreCtx<'_>,
352        keys: &[NodeKey],
353    ) -> Result<Vec<(NodeKey, NodeId)>, GraphStoreError>;
354
355    // --- embeddings -------------------------------------------------------
356    /// What this store already holds of each key's vector, index-aligned with
357    /// `keys`: `None` for a key that is unknown, tombstoned or outside the
358    /// caller's scope (anti-enumeration, as `resolve_node_ids`).
359    ///
360    /// The coordinator reads this *before* embedding a batch so a node whose
361    /// text has not changed is not embedded again — the difference between a
362    /// re-sync that costs what it changes and one that costs a full import.
363    /// Read outside the write transaction on purpose: it is an optimization,
364    /// and the transaction's own `decide_vector` still settles every state.
365    async fn embedding_state(
366        &self,
367        ctx: &StoreCtx<'_>,
368        keys: &[NodeKey],
369    ) -> Result<Vec<Option<EmbeddingState>>, GraphStoreError>;
370}
371
372/// What a store holds of one node's vector, for the coordinator's skip
373/// decision. Two facts, because both are needed: a vector is worth keeping
374/// only if it was made from the node's *current* text (`input_hash`) and is
375/// rankable under the *current* space (`vector_epoch`).
376#[derive(Clone, Debug, PartialEq, Eq)]
377pub struct EmbeddingState {
378    /// Hash of the text the stored vector was made from, when a vector is
379    /// stored; the hash of the current input otherwise.
380    pub input_hash: Option<String>,
381    /// The epoch the stored vector is current under. `None` when there is no
382    /// vector, or when it is stale.
383    pub vector_epoch: Option<i64>,
384}
385
386/// Engine-side failure vocabulary.
387#[derive(Debug, Error)]
388#[non_exhaustive]
389pub enum GraphEngineError {
390    /// The engine cannot enforce a property of this scope. The port serves
391    /// the request on the fallback hop and logs the reason — a typed error,
392    /// never a best-effort weaker predicate.
393    #[error("scope not enforceable: {reason}")]
394    ScopeNotEnforceable { reason: String },
395    #[error("unsupported: {what}")]
396    Unsupported { what: &'static str },
397    #[error("engine unavailable: {reason}")]
398    Unavailable { reason: String },
399    #[error("deadline exceeded")]
400    Deadline,
401    #[error("cancelled")]
402    Cancelled,
403    #[error("internal engine error: {0}")]
404    Internal(String),
405}
406
407/// Directed one-hop expansion request. Direction is explicit because the
408/// undirected shorthand plans as an all-vertex probe; expansion is a one-hop
409/// primitive because multi-hop chain patterns enumerate paths and explode on
410/// hubs.
411pub struct ExpandRequest {
412    pub frontier: Vec<NodeId>,
413    pub direction: Direction,
414    /// Per-hop restriction, already resolved to registered types.
415    pub edge_types: Option<TypeIdSet>,
416    /// Per-hop restriction (labels are deferred; engines may answer
417    /// `Unsupported`).
418    pub labels: Option<LabelFilter>,
419    pub budget: HopBudget,
420    /// Ask for `ExpandResponse::degrees` to be filled.
421    ///
422    /// Off by default because it costs a second scoped read: the edges
423    /// incident to the *reached* set, not only to the frontier. A traversal
424    /// does not need it — its caller post-processes the whole region — and a
425    /// neighborhood projection cannot do without it, because that is what
426    /// decides which neighbours of a hub survive the node budget.
427    pub with_degrees: bool,
428}
429
430pub struct ExpandResponse {
431    pub reached: Vec<NodeId>,
432    /// Each reached node's degree in the authorized subgraph, index-aligned
433    /// with `reached`. Empty unless the request asked for it.
434    ///
435    /// The engine is the only party that can say: a reached node is an
436    /// internal id and an `EdgeRef` names its endpoints by producer key, so
437    /// nothing above this port can join the two without another read. Every
438    /// edge counted has passed the caller's scope, so this is the degree
439    /// *inside the authorized subgraph* and never a global one the caller
440    /// cannot see (Authorization Model: "degree ordering, budgets and
441    /// truncation are computed on authorized rows only"). It is what lets a
442    /// neighborhood projection keep the structural core when a hub exceeds
443    /// the node budget (`fr-neighborhood-projection`).
444    ///
445    /// A count is a lower bound when the hop reports `EdgeScanCap`: the scan
446    /// stopped at the budget, so a node may have edges it did not see. The
447    /// ranking is then approximate — which the truncation reason says.
448    pub degrees: Vec<u32>,
449    pub edges: Vec<EdgeRef>,
450    /// Never silent.
451    pub truncated: Option<TruncationReason>,
452    /// Which backend produced this answer.
453    ///
454    /// **Found while building the prototype.** The pattern backend declines by
455    /// falling back, and the decline was recorded only in a log line. A log
456    /// line is not something a test can assert on, so the suite could not tell
457    /// a pattern hop that ran from one that failed and was silently served by
458    /// the two-query hop instead -- which is exactly what happened, for every
459    /// traversal, when the pattern lost its anchor. Reporting the backend on
460    /// the response is what makes "the pattern actually served this" an
461    /// assertion rather than an assumption.
462    pub served_by: HopBackend,
463}
464
465/// The hop backends of ADR-0005, as the answer reports them.
466#[derive(Clone, Copy, Debug, PartialEq, Eq)]
467pub enum HopBackend {
468    /// One scoped `GRAPH_TABLE` statement.
469    Pattern,
470    /// Two scoped queries per hop; always available.
471    TwoQuery,
472}
473
474/// The engine's applied `(source epoch, graph revision)` position. The epoch
475/// is a non-reusable timeline identifier, so a projection that survived a
476/// point-in-time restore of the source database is detected rather than
477/// served.
478pub struct EngineCursor {
479    pub revision: GraphRevision,
480}
481
482pub struct ShortestPathRequest {
483    pub from: NodeId,
484    pub to: NodeId,
485    pub max_depth: u8,
486}
487
488pub struct PathResponse {
489    pub nodes: Vec<NodeId>,
490    pub edges: Vec<EdgeRef>,
491}
492
493/// Declared-capability pattern matching (not shipped by the built-in engine).
494pub struct PatternRequest {
495    pub pattern: String,
496}
497
498pub struct PatternResponse {
499    pub rows: Vec<Vec<NodeId>>,
500}
501
502/// The traversal-engine plugin contract
503/// (`cpt-cf-graph-storage-contract-graph-engine-plugin`).
504#[async_trait]
505pub trait GraphEngineV1: Send + Sync + 'static {
506    fn capabilities(&self) -> EngineCapabilities;
507
508    async fn cursor(&self, ctx: &StoreCtx<'_>) -> Result<EngineCursor, GraphEngineError>;
509
510    /// Directed one-hop expansion of an authorized frontier. Chained by the
511    /// caller with per-hop dedup; the engine never expands beyond one hop, so
512    /// budgets and authorization are re-evaluated between hops rather than
513    /// inside an opaque traversal.
514    async fn expand(
515        &self,
516        ctx: &StoreCtx<'_>,
517        req: ExpandRequest,
518    ) -> Result<ExpandResponse, GraphEngineError>;
519
520    /// Declared capabilities only; otherwise `GraphEngineError::Unsupported`.
521    async fn shortest_path(
522        &self,
523        ctx: &StoreCtx<'_>,
524        req: ShortestPathRequest,
525    ) -> Result<PathResponse, GraphEngineError>;
526    async fn match_pattern(
527        &self,
528        ctx: &StoreCtx<'_>,
529        req: PatternRequest,
530    ) -> Result<PatternResponse, GraphEngineError>;
531}
532
533/// What the Embedding Coordinator decided about one node, for the store to
534/// write. Index-aligned with `IngestRequest::nodes` — the same convention
535/// [`EmbedResponse::vectors`] uses, and for the same reason: any other
536/// association would have to be keyed on something a batch may legitimately
537/// repeat.
538#[derive(Clone, Debug, PartialEq)]
539pub struct NodeEmbedding {
540    /// The vector, or `None` when this request did not ask for embedding.
541    pub vector: Option<Vec<f32>>,
542    /// Canonical hash of the text this node embeds from, computed whether or
543    /// not it was embedded. It is what tells a later ingest whether a
544    /// preserved vector still describes the node — the difference between a
545    /// *preserved* vector and a *stale* one.
546    pub input_hash: String,
547}
548
549impl NodeEmbedding {
550    #[must_use]
551    pub fn computed(vector: Vec<f32>, input_hash: String) -> Self {
552        Self {
553            vector: Some(vector),
554            input_hash,
555        }
556    }
557
558    #[must_use]
559    pub fn skipped(input_hash: String) -> Self {
560        Self {
561            vector: None,
562            input_hash,
563        }
564    }
565}
566
567/// The vector arm of one search, as the coordinator resolved it.
568///
569/// Absent when the request asked for no vector arm, or when no comparable
570/// embedding space is in force. A store never embeds anything itself: query
571/// text and ingest text must go through one provider, and only the
572/// coordinator holds it.
573#[derive(Clone, Debug, PartialEq)]
574pub struct VectorArm {
575    pub query_vector: Vec<f32>,
576    /// Only vectors of this epoch may be ranked. Vectors of any other epoch,
577    /// and vectors whose input has since changed, are not comparable with the
578    /// query and must not appear.
579    pub epoch: i64,
580}
581
582/// The embedding decisions of one ingest batch.
583#[derive(Clone, Debug, Default, PartialEq)]
584pub struct EmbeddingPlan {
585    /// Epoch to stamp new vectors with. `None` means no comparable space is
586    /// in force, so no vector may be written or read.
587    pub epoch: Option<i64>,
588    /// One entry per node of the request, in order.
589    pub nodes: Vec<NodeEmbedding>,
590}
591
592/// Provider-side failure vocabulary. A provider failure fails the ingest
593/// batch; it is never downgraded to an unembedded write.
594#[derive(Debug, Error)]
595#[non_exhaustive]
596pub enum EmbeddingProviderError {
597    #[error("provider unavailable: {reason}")]
598    Unavailable { reason: String },
599    #[error("embedding space mismatch")]
600    SpaceMismatch,
601    #[error("deadline exceeded")]
602    Deadline,
603    #[error("cancelled")]
604    Cancelled,
605    #[error("internal provider error: {0}")]
606    Internal(String),
607}
608
609/// Batched, not per item: the batch is where a remote provider's round trip
610/// is amortized.
611pub struct EmbedRequest {
612    pub inputs: Vec<String>,
613    pub budget: RemainingBudget,
614    pub cancel: CancellationToken,
615}
616
617pub struct EmbedResponse {
618    /// Aligned with `inputs` by index; a provider that cannot return one
619    /// vector per input fails the call rather than returning a short vector.
620    pub vectors: Vec<Vec<f32>>,
621    /// Echoed so a mismatch is caught at use, not only at configuration.
622    pub space: EmbeddingSpaceId,
623}
624
625/// The embedding-provider plugin contract
626/// (`cpt-cf-graph-storage-contract-embedding-provider`).
627#[async_trait]
628pub trait EmbeddingProviderV1: Send + Sync + 'static {
629    /// Model artifact, tokenizer artifact, preprocessing and pooling
630    /// configuration — not just a dimension.
631    fn embedding_space(&self) -> &EmbeddingSpaceId;
632    fn dimension(&self) -> u32;
633
634    async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, EmbeddingProviderError>;
635
636    async fn health(&self) -> Result<(), EmbeddingProviderError>;
637}