Skip to main content

graph_storage_sdk/
models.rs

1//! Transport-agnostic models of the graph-storage contract.
2//!
3//! These types cross three boundaries — the `ClientHub` trait, the REST DTO
4//! layer (which owns all serde), and the plugin contracts — so they carry no
5//! serde derives, no HTTP types and no database types. Payloads are arbitrary
6//! GTS-validated JSON and travel as [`serde_json::Value`].
7
8use std::collections::BTreeSet;
9use std::time::{Duration, Instant};
10
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14/// Tenant identity, as carried by the platform security context.
15pub type TenantId = Uuid;
16
17/// Internal node identity. Surrogate and per-tenant: two tenants may both own
18/// a node `17`, so it is never meaningful outside a tenant-scoped call.
19pub type NodeId = i64;
20
21/// Internal edge identity, with the same per-tenant caveat as [`NodeId`].
22pub type EdgeId = i64;
23
24/// Producer-supplied stable node key, unique within a tenant.
25///
26/// Stable is a commitment, not a hint: there is no re-key operation, so
27/// ingesting under a new key creates a different node. Edge keys are derived
28/// from their endpoints' keys, so re-keying a node re-keys every edge incident
29/// to it, and a tombstoned key cannot be reused before purge. The encoding a
30/// producer chooses for its keys is part of the same commitment. PRD
31/// `fr-stable-identity` states the consequences in full.
32pub type NodeKey = String;
33
34/// Deterministic edge key derived from (type, src, dst, discriminator).
35pub type EdgeKey = String;
36
37/// Canonical GTS type identifier (`gts.vendor.package._.type.v1~` form).
38pub type GtsTypeId = String;
39
40/// Interned label identity.
41pub type LabelId = i32;
42
43// ---------------------------------------------------------------------------
44// Closed enums
45// ---------------------------------------------------------------------------
46
47/// A wire or storage string that names no variant of a closed enum.
48///
49/// The Closed Enum Contract's third rule forbids mapping such a value onto a
50/// known variant, so every decoder in this crate refuses by name and hands
51/// the offending spelling back for the caller to report. An `Unknown(String)`
52/// variant would be the other permitted answer; a refusal is chosen because
53/// these values reach authorization and outcome reporting, where carrying an
54/// uninterpretable value forward is worse than stopping.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct UnknownVariant {
57    /// The enum the value was being decoded into.
58    pub expected: &'static str,
59    /// The value as it arrived, so the report names it.
60    pub found: String,
61}
62
63impl std::fmt::Display for UnknownVariant {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(
66            f,
67            "`{}` is not a known {} in this version",
68            self.found, self.expected
69        )
70    }
71}
72
73impl std::error::Error for UnknownVariant {}
74
75/// Both directions of one closed enum's single spelling, from one list.
76///
77/// The encoder and the decoder are generated from the same table, so they
78/// cannot drift apart, and the decoder has no default arm to acquire: there
79/// is nowhere in the generated code for a `_ =>` to be added. Rule 3 is then
80/// a property of this macro rather than a convention each call site keeps.
81macro_rules! closed_enum {
82    ($name:ident, $label:literal { $($variant:ident => $spelling:literal),+ $(,)? }) => {
83        impl $name {
84            /// The one spelling this variant has, in storage and on the wire.
85            #[must_use]
86            pub fn as_str(&self) -> &'static str {
87                match self {
88                    $(Self::$variant => $spelling,)+
89                }
90            }
91        }
92
93        impl std::str::FromStr for $name {
94            type Err = UnknownVariant;
95
96            fn from_str(value: &str) -> Result<Self, Self::Err> {
97                match value {
98                    $($spelling => Ok(Self::$variant),)+
99                    other => Err(UnknownVariant {
100                        expected: $label,
101                        found: other.to_owned(),
102                    }),
103                }
104            }
105        }
106
107        impl TryFrom<&str> for $name {
108            type Error = UnknownVariant;
109
110            fn try_from(value: &str) -> Result<Self, Self::Error> {
111                value.parse()
112            }
113        }
114
115        impl std::fmt::Display for $name {
116            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117                f.write_str(self.as_str())
118            }
119        }
120    };
121}
122
123// ---------------------------------------------------------------------------
124// Ontology
125// ---------------------------------------------------------------------------
126
127/// Kind of a registrable GTS type.
128///
129/// One of this crate's **closed enums**: a fixed set that is stored as `TEXT`
130/// under a `CHECK` constraint and carried over REST as a plain string, so the
131/// storage form and the wire form are the same string. DESIGN
132/// § Closed Enum Contract is normative for all of them, and states the three
133/// rules a client depends on: a spelling never changes meaning and is never
134/// reused; adding a variant is compatible while removing or renaming one is
135/// breaking; and an unrecognized value must be carried through or refused by
136/// name, never mapped onto a known variant or defaulted -- an unknown outcome
137/// decoded as `ok` turns a value the server chose into one it denied. The set
138/// is not an extension point: a deployment cannot add to it.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum TypeKind {
141    Node,
142    Edge,
143    Attribute,
144}
145
146closed_enum!(TypeKind, "type kind" {
147    Node => "node",
148    Edge => "edge",
149    Attribute => "attribute",
150});
151
152/// One type submitted for registration.
153#[derive(Clone, Debug, PartialEq)]
154pub struct TypeRegistration {
155    /// Canonical GTS identifier; must derive from one of the gear's family
156    /// types (base -> family -> producer type, two derivations max).
157    pub type_id: GtsTypeId,
158    /// The type's draft-07 JSON Schema.
159    pub schema: serde_json::Value,
160}
161
162/// Trait values resolved across the whole derivation chain, stored with the
163/// registered type so batch validation never repeats the walk.
164#[derive(Clone, Debug, Default, PartialEq)]
165pub struct EffectiveTraits {
166    /// `owned` / `reference` / `phantom` for nodes, `static` / `analysis` for
167    /// edges. `None` only on abstract types, which are uninstantiable.
168    pub family: Option<String>,
169    pub scope_managed: bool,
170    pub emit_events: bool,
171    /// JSON-pointer payload paths admitted to `$filter` / `$orderby`.
172    pub index: Vec<String>,
173    /// JSON-pointer payload paths folded into the lexical search text.
174    pub full_text_search: Vec<String>,
175    /// JSON-pointer payload paths folded into the embedding input.
176    pub vector_search: Vec<String>,
177    /// Edge endpoint constraints, GTS patterns (edges only).
178    pub src_types: Vec<String>,
179    pub dst_types: Vec<String>,
180}
181
182// ---------------------------------------------------------------------------
183// Readiness
184// ---------------------------------------------------------------------------
185
186/// The state of one capability (DESIGN § Readiness Matrix).
187///
188/// `NotImplemented` is this gear's addition to the matrix's three, and it is
189/// the honest answer for a row the matrix specifies and this iteration does
190/// not ship: reporting such a component `Healthy` would be a lie an operator
191/// acts on, and omitting it would hide a capability they are entitled to ask
192/// about.
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194pub enum ReadinessState {
195    Healthy,
196    Degraded,
197    Unhealthy,
198    NotImplemented,
199}
200
201closed_enum!(ReadinessState, "readiness state" {
202    Healthy => "healthy",
203    Degraded => "degraded",
204    Unhealthy => "unhealthy",
205    NotImplemented => "not_implemented",
206});
207
208/// One row of the readiness matrix, as the endpoint reports it.
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub struct ComponentReadiness {
211    /// The matrix's own name for the component.
212    pub component: String,
213    pub state: ReadinessState,
214    /// What is wrong, named rather than implied. `None` when healthy.
215    pub problem: Option<String>,
216    /// What this state rejects, in the words of the matrix's third column.
217    pub blocked: Option<String>,
218    /// The condition being waited on, so an operator knows whether to act.
219    pub recovery: Option<String>,
220}
221
222impl ComponentReadiness {
223    #[must_use]
224    pub fn healthy(component: &str) -> Self {
225        Self {
226            component: component.to_owned(),
227            state: ReadinessState::Healthy,
228            problem: None,
229            blocked: None,
230            recovery: None,
231        }
232    }
233
234    #[must_use]
235    pub fn new(
236        component: &str,
237        state: ReadinessState,
238        problem: &str,
239        blocked: &str,
240        recovery: &str,
241    ) -> Self {
242        Self {
243            component: component.to_owned(),
244            state,
245            problem: Some(problem.to_owned()),
246            blocked: Some(blocked.to_owned()),
247            recovery: Some(recovery.to_owned()),
248        }
249    }
250
251    /// Whether this component's state takes the whole gear out of service.
252    ///
253    /// Not simply "is it unhealthy": the matrix is explicit that an
254    /// embedding-space mismatch leaves the gear ready and blocks only the
255    /// vector arms, while an unreachable database admits no traffic at all.
256    /// The aggregate therefore asks the component, not the state.
257    #[must_use]
258    pub fn fatal(&self) -> bool {
259        self.state == ReadinessState::Unhealthy && self.component != EMBEDDING_SPACE
260    }
261}
262
263/// Matrix row names, spelled once so the endpoint and the documentation cannot
264/// drift apart.
265pub const DATABASE: &str = "database_and_migrations";
266pub const SQLPGQ: &str = "server_major_and_sqlpgq";
267pub const EMBEDDING_PROVIDER: &str = "embedding_provider";
268pub const EMBEDDING_SPACE: &str = "embedding_space_identity";
269pub const GRAPH_ENGINE: &str = "graph_engine_plugin";
270pub const AUTHZ: &str = "authz_resolver";
271pub const TYPES_REGISTRY: &str = "types_registry";
272pub const DYNAMIC_INDEXES: &str = "dynamic_indexes";
273pub const TENANT_RECONCILIATION: &str = "tenant_reconciliation";
274pub const METRIC_ANNOTATION: &str = "metric_annotation_source";
275
276/// What `GET /health/ready` answers.
277#[derive(Clone, Debug, PartialEq, Eq)]
278pub struct Readiness {
279    /// Ready when no component whose failure blocks everything is unhealthy.
280    pub ready: bool,
281    pub components: Vec<ComponentReadiness>,
282}
283
284impl Readiness {
285    #[must_use]
286    pub fn of(components: Vec<ComponentReadiness>) -> Self {
287        Self {
288            ready: !components.iter().any(ComponentReadiness::fatal),
289            components,
290        }
291    }
292}
293
294/// One source namespace and the producer principal bound to it.
295///
296/// The authority the ingest path consults: a reference node's payload names a
297/// `source.system`, and this row decides who may speak for it. `node.
298/// owner_principal` records who *created* a row and never changes; this row
299/// records who may write it now, so an ownership transfer is a change here and
300/// not a rewrite of history.
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct SourceNamespaceOwner {
303    /// The `source.system` value of a reference node's identity triple.
304    pub namespace: String,
305    pub owner_principal: String,
306    /// When the namespace was first claimed.
307    pub claimed_at: OffsetDateTime,
308    /// The principal the namespace was taken from, if it was ever transferred.
309    pub previous_owner: Option<String>,
310    pub transferred_at: Option<OffsetDateTime>,
311    /// The subject that performed the transfer — the audit trail of the one
312    /// administrative flow that can move a namespace.
313    pub transferred_by: Option<Subject>,
314}
315
316/// A registered type as the gear reports it.
317#[derive(Clone, Debug, PartialEq)]
318pub struct TypeRecord {
319    pub type_id: GtsTypeId,
320    /// Deterministic `UUIDv5` of the GTS identifier (the platform derivation).
321    pub type_uuid: Uuid,
322    pub kind: TypeKind,
323    /// Abstract types (the bases and families) cannot be instantiated.
324    pub is_abstract: bool,
325    pub schema: serde_json::Value,
326    pub effective_traits: EffectiveTraits,
327    pub created_at: OffsetDateTime,
328    /// Which retained definition of this identifier is in force: `1` until the
329    /// type is first updated in place, then one more per accepted update
330    /// (types-registry ADR-0005 calls each of them a retained revision).
331    pub revision: i32,
332}
333
334/// Filter for listing registered types.
335#[derive(Clone, Debug, Default, PartialEq)]
336pub struct TypeQuery {
337    pub kind: Option<TypeKind>,
338    /// GTS identifier pattern, resolved by the shared GTS implementation —
339    /// never compiled to SQL text.
340    pub pattern: Option<String>,
341    pub top: Option<u32>,
342    pub cursor: Option<String>,
343}
344
345/// A resolved set of registered types, the single representation on which a
346/// caller's type filter and an authorizing permission's pattern intersect.
347#[derive(Clone, Debug, Default, PartialEq, Eq)]
348pub struct TypeIdSet(pub BTreeSet<GtsTypeId>);
349
350impl TypeIdSet {
351    #[must_use]
352    pub fn intersect(&self, other: &Self) -> Self {
353        Self(self.0.intersection(&other.0).cloned().collect())
354    }
355
356    #[must_use]
357    pub fn is_empty(&self) -> bool {
358        self.0.is_empty()
359    }
360
361    #[must_use]
362    pub fn contains(&self, type_id: &str) -> bool {
363        self.0.contains(type_id)
364    }
365}
366
367// ---------------------------------------------------------------------------
368// Type evolution (registering a changed schema under a known identifier)
369// ---------------------------------------------------------------------------
370
371/// What a registration batch may do to an identifier that is already
372/// registered with a *different* schema.
373///
374/// The platform decided the policy before the gear did: types-registry
375/// ADR-0004 says a major-only GTS id names a mutable logical entity whose
376/// backward-compatible updates keep that id, and ADR-0003 fixes the direction
377/// (`BACKWARD`), the baseline (the current revision) and the posture (an
378/// undecidable check is a refusal). This enum is only the per-request switch
379/// between the gear's historical behaviour and that policy.
380#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
381pub enum OnExisting {
382    /// A changed schema is a conflict — the gear's behaviour before type
383    /// updates existed, and still the default so no existing caller changes.
384    #[default]
385    Reject,
386    /// Admit the change when it is admissible; refuse it, with the offending
387    /// schema locations, when it is not.
388    Update,
389}
390
391/// One step of a payload migration.
392///
393/// A closed set, not an expression language. Three steps covered every
394/// incompatible edit the Studio domain model produced in three days, and a
395/// closed set is what lets every path be a checked literal and every value a
396/// bound parameter.
397#[derive(Clone, Debug, PartialEq)]
398pub enum MigrationStep {
399    /// Move a value to another path, if the source is present. An absent
400    /// source is a no-op: a migration fills gaps, it does not invent values.
401    Rename { from: String, to: String },
402    /// Set `path` when nothing is there. A present value is left alone —
403    /// otherwise a "default" would be an overwrite.
404    Default {
405        path: String,
406        value: serde_json::Value,
407    },
408    /// Remove `path` if present.
409    Drop { path: String },
410}
411
412impl MigrationStep {
413    /// The paths this step touches, for the overlap check.
414    #[must_use]
415    pub fn paths(&self) -> Vec<&str> {
416        match self {
417            Self::Rename { from, to } => vec![from.as_str(), to.as_str()],
418            Self::Default { path, .. } | Self::Drop { path } => vec![path.as_str()],
419        }
420    }
421}
422
423/// What to do with one type's stored payloads so they satisfy the candidate.
424#[derive(Clone, Debug, PartialEq)]
425pub struct MigrationSpec {
426    pub type_id: GtsTypeId,
427    pub steps: Vec<MigrationStep>,
428}
429
430/// Per-batch registration options.
431#[derive(Clone, Debug, Default, PartialEq)]
432pub struct TypeRegistrationOptions {
433    pub on_existing: OnExisting,
434    /// Admit a change the schemas cannot prove compatible when every stored
435    /// row of the type still validates against the candidate.
436    ///
437    /// This is deliberately a second, explicit ground for admission rather
438    /// than a relaxation of the first: it is a statement about *this tenant's
439    /// current rows*, not about the accepted instance sets, and it costs a
440    /// scan of the type bounded by `type_update_max_rows`.
441    pub revalidate: bool,
442    /// Compute and report every verdict, write nothing.
443    pub dry_run: bool,
444    /// Payload migrations, at most one per type in the batch.
445    ///
446    /// A migration is the third ground for admitting a change, and the only one
447    /// that *changes* data: the steps are applied to every live row of the
448    /// type, the result is validated against the candidate, and nothing is
449    /// written unless every row passes. It requires a schema change to migrate
450    /// towards — a migration on an unchanged type would be a data-editing API
451    /// wearing a type endpoint's clothes.
452    pub migrations: Vec<MigrationSpec>,
453}
454
455impl TypeRegistrationOptions {
456    /// The migration declared for `type_id`, if any.
457    #[must_use]
458    pub fn migration_for(&self, type_id: &str) -> Option<&MigrationSpec> {
459        self.migrations.iter().find(|m| m.type_id == type_id)
460    }
461}
462
463/// How the candidate stands against the registered definition.
464#[derive(Clone, Copy, Debug, PartialEq, Eq)]
465pub enum TypeChangeState {
466    /// Nothing is registered under this identifier yet.
467    New,
468    /// Byte-identical to what is registered.
469    Unchanged,
470    /// `Valid(old) ⊆ Valid(new)` proved from the schemas.
471    Compatible,
472    /// Proved *not* to hold.
473    Incompatible,
474    /// Could be neither proved nor disproved (`gts` reports `Unknown`).
475    /// ADR-0003 fails closed on this, so it is a refusal — but a distinct one,
476    /// because the fix is a different one.
477    Undecidable,
478}
479
480closed_enum!(TypeChangeState, "type change state" {
481    New => "new",
482    Unchanged => "unchanged",
483    Compatible => "compatible",
484    Incompatible => "incompatible",
485    Undecidable => "undecidable",
486});
487
488/// One reason a directional verdict does not hold, with the schema location
489/// that carries it — so a refusal points at `$.payload` rather than saying
490/// "incompatible".
491#[derive(Clone, Debug, PartialEq, Eq)]
492pub struct SchemaDiagnostic {
493    /// Location in the resolved schema, `$` for the document root.
494    pub location: String,
495    /// Machine-readable finding kind, as `gts` names it.
496    pub finding: String,
497    pub message: String,
498}
499
500/// How one trait's declared paths changed between the two definitions.
501#[derive(Clone, Debug, PartialEq, Eq)]
502pub struct TraitChange {
503    /// `index`, `full_text_search`, `vector_search`, `src_types`, `dst_types`.
504    pub trait_name: String,
505    pub added: Vec<String>,
506    pub removed: Vec<String>,
507}
508
509/// The full verdict on one candidate: what the dry run reports and what a
510/// refusal explains itself with.
511#[derive(Clone, Debug, PartialEq, Eq)]
512pub struct TypeChange {
513    pub type_id: GtsTypeId,
514    pub state: TypeChangeState,
515    /// `compatible` / `incompatible` / `unknown`; the direction ADR-0003
516    /// enforces.
517    pub backward: String,
518    /// Computed and reported, never enforced — the same posture as the
519    /// registry. It tells a producer whether an old reader still accepts new
520    /// payloads.
521    pub forward: String,
522    /// Evidence for the backward verdict.
523    pub diagnostics: Vec<SchemaDiagnostic>,
524    pub traits_changed: Vec<TraitChange>,
525    /// Live rows of this type, when the operation needed to know.
526    pub rows: Option<u64>,
527    /// Rows a migration changed (or would change, in a dry run).
528    pub rows_rewritten: Option<u64>,
529    /// Object levels of the candidate where a *later* definition will not be
530    /// able to add an optional property (`ContentModel::is_evolvable_in_place`).
531    /// Reported so "your next edit will be a major" is a warning today rather
532    /// than a surprise later.
533    pub levels_not_evolvable_in_place: Vec<String>,
534    /// Whether this change needs more than the schemas to be admitted.
535    pub migration_required: bool,
536    /// Whether the gear would admit it under the options of this request.
537    pub admissible: bool,
538}
539
540/// Why an update was admitted. Two grounds, never conflated in a report.
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
542pub enum AdmissionBasis {
543    /// The schemas prove `Valid(old) ⊆ Valid(new)`. No row was read.
544    SchemaProved,
545    /// The schemas do not prove it; every live row of the type was validated
546    /// against the candidate instead. True of *these rows*, not of the type.
547    DataBacked { rows_validated: u64 },
548    /// The rows did not satisfy the candidate, so they were *changed* to: the
549    /// declared steps were applied to every live row and the result validated
550    /// against the candidate before anything was written.
551    Migrated {
552        rows_scanned: u64,
553        rows_rewritten: u64,
554    },
555}
556
557/// What one registration did.
558#[derive(Clone, Copy, Debug, PartialEq, Eq)]
559pub enum TypeOutcome {
560    Created,
561    /// Already registered, byte-identical, nothing written.
562    Unchanged,
563    /// The stored definition was replaced under the same identifier.
564    Updated,
565}
566
567closed_enum!(TypeOutcome, "type outcome" {
568    Created => "created",
569    Unchanged => "unchanged",
570    Updated => "updated",
571});
572
573/// A registered type plus what this call did to it.
574#[derive(Clone, Debug, PartialEq)]
575pub struct RegisteredType {
576    pub record: TypeRecord,
577    pub outcome: TypeOutcome,
578    /// Present when `outcome` is `Updated`.
579    pub basis: Option<AdmissionBasis>,
580    /// Present when the identifier was already registered, and always in a
581    /// dry run.
582    pub change: Option<TypeChange>,
583}
584
585// ---------------------------------------------------------------------------
586// Revision-bound identity (Read Consistency Contract)
587// ---------------------------------------------------------------------------
588
589/// The snapshot identity every compound read observes and reports: the
590/// deployment-wide, non-reusable source epoch paired with the per-tenant
591/// monotonic revision.
592#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
593pub struct GraphRevision {
594    pub source_epoch: i64,
595    pub revision: i64,
596}
597
598/// Handle to one open compound-read snapshot. Opaque to callers; the store
599/// that issued it resolves it back to a live snapshot.
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
601pub struct ReadSnapshot {
602    pub id: Uuid,
603    pub revision: GraphRevision,
604}
605
606// ---------------------------------------------------------------------------
607// Element envelope (fr-audit-envelope)
608// ---------------------------------------------------------------------------
609
610/// The party behind a write, in the platform's own vocabulary rather than in
611/// a vocabulary of this gear's own: `SecurityContext`'s `subject_id` and
612/// optional `subject_type`.
613///
614/// A subject and not a user because most writes into this gear arrive from an
615/// automation or a service integration, so a `user_id` member would be empty
616/// on the majority of rows and would need a second member beside it for the
617/// rest (DESIGN § API element envelope).
618#[derive(Clone, Debug, PartialEq, Eq)]
619pub struct Subject {
620    pub subject_id: Uuid,
621    /// GTS type of the acting subject, e.g.
622    /// `gts.cf.core.security.subject_user.v1~`. Optional, matching
623    /// `SecurityContext`, which does not always carry one.
624    pub subject_type: Option<GtsTypeId>,
625}
626
627impl Subject {
628    /// The producer principal this subject writes as.
629    ///
630    /// One string, derived from the subject id rather than invented beside it,
631    /// so "who wrote this row" (the audit envelope) and "who owns this
632    /// namespace" (the ownership boundary) cannot disagree. The subject *type*
633    /// is deliberately not part of it: the id is already unique, and folding
634    /// the type in would make one principal look like two the day a producer
635    /// is re-typed.
636    #[must_use]
637    pub fn principal(&self) -> String {
638        self.subject_id.to_string()
639    }
640
641    /// The subject a `SecurityContext` names.
642    #[must_use]
643    pub fn from_security_context(ctx: &toolkit_security::SecurityContext) -> Self {
644        Self {
645            subject_id: ctx.subject_id(),
646            subject_type: ctx.subject_type().map(ToOwned::to_owned),
647        }
648    }
649}
650
651/// The gear-assigned half of an element, identical for every node and every
652/// edge and described by the API schema rather than by the element's GTS type
653/// -- a producer can neither supply nor extend it, and a type registered
654/// statically in the types-registry has nothing to put in it.
655///
656/// It is read-only on every write surface: an envelope member a producer
657/// sends is ignored rather than rejected, so a document read from the API can
658/// be sent back unchanged.
659#[derive(Clone, Debug, PartialEq, Eq)]
660pub struct ElementEnvelope {
661    pub tenant_id: Uuid,
662    /// The element's key: a node's producer-supplied `node_key`, an edge's
663    /// gear-derived `edge_key`.
664    pub key: String,
665    pub created_at: OffsetDateTime,
666    pub created_by: Subject,
667    pub updated_at: OffsetDateTime,
668    pub updated_by: Subject,
669    /// Soft-delete tombstone; absent on a live element.
670    pub deleted_at: Option<OffsetDateTime>,
671    pub deleted_by: Option<Subject>,
672    /// The revision the read that produced this element observed.
673    ///
674    /// Per element rather than per response because the tabular projection
675    /// answers inside `toolkit_odata::Page`, which carries items and cursors
676    /// and nothing else -- so this is the only place that read path can
677    /// report the snapshot it observed (PRD § fr-tabular-projection).
678    pub graph_revision: GraphRevision,
679}
680
681// ---------------------------------------------------------------------------
682// Ingest
683// ---------------------------------------------------------------------------
684
685/// A node submitted for ingest. An upsert replaces the row's mutable state
686/// wholesale: a field the request omits is cleared, never preserved.
687#[derive(Clone, Debug, Default, PartialEq)]
688pub struct NodeSpec {
689    pub node_key: NodeKey,
690    pub type_id: GtsTypeId,
691    pub name: Option<String>,
692    /// GTS-validated attributes. `None` = no opinion on an existing row's
693    /// payload is *not* offered — ingest is replace, so `None` clears.
694    pub payload: Option<serde_json::Value>,
695    /// Optional compare-and-set on the node's stored version.
696    ///
697    /// A stored version is 1 or more and advances on every update. `Some(n)`
698    /// with `n >= 1` requires the stored version to be exactly `n`, and is a
699    /// conflict when it is not -- including when no node is stored under the
700    /// key at all. `Some(0)` means "there must be no node under this key":
701    /// the one conditional a producer can make without a version to read
702    /// back, and the way to claim a key exactly once across writers. Edges
703    /// carry no version: their identity is derived from their endpoints.
704    pub expected_version: Option<i64>,
705}
706
707/// An edge submitted for ingest, addressed by its endpoint node keys.
708#[derive(Clone, Debug, Default, PartialEq)]
709pub struct EdgeSpec {
710    pub type_id: GtsTypeId,
711    pub src_node_key: NodeKey,
712    pub dst_node_key: NodeKey,
713    /// Distinguishes parallel edges of one type between one endpoint pair.
714    pub discriminator: Option<String>,
715    pub payload: Option<serde_json::Value>,
716}
717
718/// Declarative scope replacement carried by an ingest batch.
719#[derive(Clone, Debug, PartialEq, Eq)]
720pub struct ReplaceScope {
721    /// Scope attribute of the canonical identity
722    /// `(tenant, owning producer, scope attribute, scope value)`.
723    pub attribute: String,
724    pub value: String,
725    /// Monotonic source generation. Older than the recorded one is rejected as
726    /// stale; equal with identical content is a replay; equal with different
727    /// content conflicts.
728    pub generation: i64,
729}
730
731/// Per-request ingest options.
732#[derive(Clone, Debug, Default, PartialEq, Eq)]
733pub struct IngestOptions {
734    /// Create phantom endpoint nodes for edges whose endpoints are not in the
735    /// batch and not stored. `None` = the deployment default (on).
736    pub create_phantoms: Option<bool>,
737    /// Return per-item outcomes on success (errors are always per item).
738    pub report_per_item: bool,
739    /// Whether this batch's nodes are embedded. `None` = the deployment
740    /// default (on). `false` keeps existing vectors rather than clearing
741    /// them: a metadata-only re-sync should not cost a re-embedding pass, and
742    /// should not silently empty the vector arm either.
743    pub embed: Option<bool>,
744}
745
746/// One atomic ingest batch.
747#[derive(Clone, Debug, Default, PartialEq)]
748pub struct IngestRequest {
749    pub nodes: Vec<NodeSpec>,
750    pub edges: Vec<EdgeSpec>,
751    pub options: IngestOptions,
752    pub replace_scope: Option<ReplaceScope>,
753    /// Producer-chosen idempotency key (the REST layer reads the same value
754    /// from the `Idempotency-Key` header).
755    pub idempotency_key: Option<String>,
756}
757
758/// Aggregate counters of one committed batch.
759#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
760pub struct IngestCounts {
761    pub nodes_inserted: u64,
762    pub nodes_updated: u64,
763    pub nodes_unchanged: u64,
764    pub edges_inserted: u64,
765    pub edges_updated: u64,
766    pub edges_unchanged: u64,
767    pub phantoms_created: u64,
768    pub phantoms_materialized: u64,
769    /// Rows tombstoned by scope replacement.
770    pub scope_removed_nodes: u64,
771    pub scope_removed_edges: u64,
772}
773
774/// Which collection an ingest item belongs to.
775#[derive(Clone, Copy, Debug, PartialEq, Eq)]
776pub enum ItemFamily {
777    Node,
778    Edge,
779}
780
781/// Per-item outcome, reported when `options.report_per_item` is set.
782#[derive(Clone, Debug, PartialEq, Eq)]
783pub enum ItemOutcome {
784    Inserted,
785    Updated,
786    Unchanged,
787    Materialized,
788}
789
790closed_enum!(ItemOutcome, "item outcome" {
791    Inserted => "inserted",
792    Updated => "updated",
793    Unchanged => "unchanged",
794    Materialized => "materialized",
795});
796
797/// One per-item validation failure. A batch with any of these commits nothing.
798#[derive(Clone, Debug, PartialEq, Eq)]
799pub struct ItemError {
800    pub index: usize,
801    pub family: ItemFamily,
802    pub gts_type: Option<GtsTypeId>,
803    /// JSON pointer to the offending value, when the failure is positional.
804    pub pointer: Option<String>,
805    pub message: String,
806}
807
808/// Outcome of one ingest call.
809#[derive(Clone, Debug, PartialEq, Eq)]
810pub struct IngestOutcome {
811    /// Revision the graph reached once the batch committed (unchanged when
812    /// the batch converged without modifying anything).
813    pub revision: GraphRevision,
814    /// True when an idempotency receipt answered the call without touching
815    /// state.
816    ///
817    /// A replayed outcome is the record of the first attempt's commit, not a
818    /// view of the graph now: `revision` and `counts` are what that commit
819    /// reached and did, and a later write — a scope replacement included —
820    /// may since have changed or removed what it wrote. It carries no
821    /// per-item lists, because the receipt keeps counts only. A producer that
822    /// needs current state reads it, and compares `revision` with the
823    /// tenant's current one to know whether anything has committed since.
824    pub replayed: bool,
825    pub counts: IngestCounts,
826    pub per_item_nodes: Option<Vec<ItemOutcome>>,
827    pub per_item_edges: Option<Vec<ItemOutcome>>,
828}
829
830/// Soft-delete target.
831#[derive(Clone, Debug, PartialEq, Eq)]
832pub enum DeleteRequest {
833    /// Tombstone a node together with its incident edges.
834    Node(NodeKey),
835    /// Tombstone one edge.
836    Edge(EdgeKey),
837}
838
839/// Outcome of a soft delete.
840#[derive(Clone, Copy, Debug, PartialEq, Eq)]
841pub struct DeleteOutcome {
842    pub revision: GraphRevision,
843    pub tombstoned_nodes: u64,
844    pub tombstoned_edges: u64,
845}
846
847// ---------------------------------------------------------------------------
848// Node read / projection
849// ---------------------------------------------------------------------------
850
851/// Edge incidence direction relative to a node.
852#[derive(Clone, Copy, Debug, PartialEq, Eq)]
853pub enum AdjacencySide {
854    Outgoing,
855    Incoming,
856}
857
858closed_enum!(AdjacencySide, "adjacency side" {
859    Outgoing => "outgoing",
860    Incoming => "incoming",
861});
862
863/// One incident edge in a node read.
864#[derive(Clone, Debug, PartialEq, Eq)]
865pub struct AdjacencyEntry {
866    pub edge_key: EdgeKey,
867    pub edge_type_id: GtsTypeId,
868    pub side: AdjacencySide,
869    pub neighbor_key: NodeKey,
870    pub neighbor_type_id: GtsTypeId,
871}
872
873/// A node as read paths return it.
874#[derive(Clone, Debug, PartialEq)]
875pub struct NodeView {
876    pub node_key: NodeKey,
877    pub type_id: GtsTypeId,
878    pub name: Option<String>,
879    pub payload: Option<serde_json::Value>,
880    pub has_embedding: bool,
881    pub labels: Vec<String>,
882    pub adjacency: Vec<AdjacencyEntry>,
883    pub adjacency_truncated: bool,
884    /// Gear-assigned audit envelope (`fr-audit-envelope`).
885    pub envelope: ElementEnvelope,
886}
887
888/// An edge as the edge read returns it.
889///
890/// The topology references (`EdgeRef`, `AdjacencyEntry`) stay what they are --
891/// a key, a type and two endpoints. This is the element form: payload and the
892/// audit envelope `fr-audit-envelope` asks every returned edge to carry.
893#[derive(Clone, Debug, PartialEq)]
894pub struct EdgeView {
895    pub edge_key: EdgeKey,
896    pub edge_type_id: GtsTypeId,
897    pub src: NodeKey,
898    pub dst: NodeKey,
899    /// Distinguishes parallel edges of one type between one endpoint pair.
900    pub discriminator: Option<String>,
901    pub payload: Option<serde_json::Value>,
902    /// Gear-assigned audit envelope (`fr-audit-envelope`).
903    pub envelope: ElementEnvelope,
904}
905
906/// One row of the tabular projection.
907#[derive(Clone, Debug, PartialEq)]
908pub struct NodeRow {
909    pub node_key: NodeKey,
910    pub type_id: GtsTypeId,
911    pub name: Option<String>,
912    pub payload: Option<serde_json::Value>,
913    /// Gear-assigned audit envelope (`fr-audit-envelope`). On this path it is
914    /// also the only carrier of the observed revision: the page wrapper is
915    /// the platform's and has no member for one.
916    pub envelope: ElementEnvelope,
917}
918
919/// A page of results with an opaque continuation token bound to the observed
920/// revision (Read Consistency Contract).
921#[derive(Clone, Debug, PartialEq)]
922pub struct Page<T> {
923    pub items: Vec<T>,
924    pub next_cursor: Option<String>,
925    pub revision: GraphRevision,
926}
927
928/// Filterable-field schema of the node projection.
929///
930/// Never constructed: it exists to feed `#[derive(ODataFilterable)]`, which
931/// generates [`NodeQueryFilterField`] and its `FilterField` impl. Declaring it
932/// here rather than on the REST DTO keeps one authority for what `$filter` and
933/// `$orderby` may name — the store's column mapping is written against this
934/// type, so a field nobody mapped cannot reach a query.
935///
936/// Payload paths are deliberately absent: they are admissible only where a
937/// type's `index` trait declares them *and* an index backs them, which this
938/// iteration does not yet build.
939#[derive(toolkit_odata_macros::ODataFilterable)]
940pub struct NodeQuery {
941    /// The producer-supplied node key.
942    #[odata(filter(kind = "String"))]
943    pub node_key: String,
944    /// The node's display name.
945    #[odata(filter(kind = "String"))]
946    pub name: String,
947    #[odata(filter(kind = "DateTimeUtc"))]
948    pub created_at: time::OffsetDateTime,
949    #[odata(filter(kind = "DateTimeUtc"))]
950    pub updated_at: time::OffsetDateTime,
951}
952
953pub use NodeQueryFilterField as NodeFilterField;
954
955/// Tabular projection query.
956///
957/// Filtering, ordering and pagination are the **platform** `OData` binding —
958/// the parsed [`toolkit_odata::ODataQuery`], carrying the `CursorV1`
959/// continuation token and its filter hash — not a second dialect of our own.
960#[derive(Clone, Debug, Default)]
961pub struct ProjectionRequest {
962    /// Restrict to these types (already intersected with the authorizing
963    /// permission's pattern by the domain layer).
964    pub type_set: Option<TypeIdSet>,
965    /// The accepted system query options, already parsed and validated.
966    pub query: toolkit_odata::ODataQuery,
967}
968
969// ---------------------------------------------------------------------------
970// Search
971// ---------------------------------------------------------------------------
972
973/// Which arm produced a hit.
974#[derive(Clone, Copy, Debug, PartialEq, Eq)]
975pub enum SearchArm {
976    Lexical,
977    Vector,
978}
979
980closed_enum!(SearchArm, "search arm" {
981    Lexical => "lexical",
982    Vector => "vector",
983});
984
985/// Search mode. Hybrid runs both arms independently and fuses them with RRF.
986#[derive(Clone, Copy, Debug, PartialEq, Eq)]
987pub enum SearchMode {
988    Lexical,
989    Vector,
990    Hybrid,
991}
992
993closed_enum!(SearchMode, "search mode" {
994    Lexical => "lexical",
995    Vector => "vector",
996    Hybrid => "hybrid",
997});
998
999/// One search request.
1000#[derive(Clone, Debug, PartialEq)]
1001pub struct SearchRequest {
1002    pub mode: SearchMode,
1003    /// Query text for the lexical arm.
1004    pub query: Option<String>,
1005    /// Per-arm candidate limit before fusion.
1006    pub arm_limit: u32,
1007    /// Result limit after fusion.
1008    pub limit: u32,
1009    /// GTS type patterns narrowing the searched set.
1010    pub type_patterns: Vec<String>,
1011}
1012
1013/// A hit's per-arm provenance: which arm matched, at what rank and raw score.
1014#[derive(Clone, Copy, Debug, PartialEq)]
1015pub struct ArmHit {
1016    pub arm: SearchArm,
1017    pub rank: u32,
1018    pub score: f64,
1019}
1020
1021/// One fused search hit.
1022#[derive(Clone, Debug, PartialEq)]
1023pub struct SearchHit {
1024    pub node_key: NodeKey,
1025    pub type_id: GtsTypeId,
1026    pub name: Option<String>,
1027    /// Fused (RRF) score.
1028    pub score: f64,
1029    pub arms: Vec<ArmHit>,
1030    /// Highlighted snippet from the lexical arm, when it matched.
1031    pub snippet: Option<String>,
1032}
1033
1034/// Search response, revision-stamped like every compound read.
1035#[derive(Clone, Debug, PartialEq)]
1036pub struct SearchResponse {
1037    pub hits: Vec<SearchHit>,
1038    pub revision: GraphRevision,
1039    /// Set when the hit list was cut short by `response_max_bytes` rather
1040    /// than by the caller's `limit`.
1041    ///
1042    /// A short list is otherwise indistinguishable from a small graph, and
1043    /// the two call for opposite reactions: one is a reason to narrow the
1044    /// query, the other a reason to stop looking.
1045    pub truncated: Option<TruncationReason>,
1046}
1047
1048// ---------------------------------------------------------------------------
1049// Traversal
1050// ---------------------------------------------------------------------------
1051
1052/// Expansion direction. `Either` is the union of the two directed scans in
1053/// one semi-join — never the undirected pattern shorthand, which plans as an
1054/// all-vertex probe.
1055#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1056pub enum Direction {
1057    Outgoing,
1058    Incoming,
1059    Either,
1060}
1061
1062closed_enum!(Direction, "direction" {
1063    Outgoing => "outgoing",
1064    Incoming => "incoming",
1065    Either => "either",
1066});
1067
1068/// Per-hop budget.
1069#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1070pub struct HopBudget {
1071    pub max_frontier: u32,
1072    pub max_edges_scanned: u64,
1073}
1074
1075/// Why an expansion or traversal stopped early. Never silent.
1076#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1077pub enum TruncationReason {
1078    FrontierCap,
1079    EdgeScanCap,
1080    NodeBudget,
1081    /// The hydrated answer reached `response_max_bytes`.
1082    ///
1083    /// Distinct from `NodeBudget`, and the difference is actionable: a node
1084    /// budget is a number the caller asked for and can raise, while this one
1085    /// says the elements were large. Asking for fewer, or for a narrower type
1086    /// set, is what helps.
1087    ResponseBytes,
1088}
1089
1090closed_enum!(TruncationReason, "truncation reason" {
1091    FrontierCap => "frontier_cap",
1092    EdgeScanCap => "edge_scan_cap",
1093    NodeBudget => "node_budget",
1094    ResponseBytes => "response_bytes",
1095});
1096
1097/// A traversed edge reference.
1098#[derive(Clone, Debug, PartialEq, Eq)]
1099pub struct EdgeRef {
1100    pub edge_key: EdgeKey,
1101    pub edge_type_id: GtsTypeId,
1102    pub src: NodeKey,
1103    pub dst: NodeKey,
1104}
1105
1106/// Label filter placeholder (labels are not shipped in this iteration; the
1107/// field exists so the plugin contract does not change when they are).
1108#[derive(Clone, Debug, PartialEq, Eq)]
1109pub struct LabelFilter {
1110    pub any_of: Vec<String>,
1111}
1112
1113/// Seeded, depth-bounded traversal request.
1114#[derive(Clone, Debug, Default, PartialEq)]
1115pub struct TraverseRequest {
1116    pub seeds: Vec<NodeKey>,
1117    pub depth: u8,
1118    /// Per-hop edge-type restriction (GTS patterns).
1119    pub edge_type_patterns: Vec<String>,
1120    /// Node-type filter applied to the output set (seeds always survive).
1121    pub node_type_patterns: Vec<String>,
1122    pub max_nodes: Option<u32>,
1123}
1124
1125/// Bounded neighborhood projection request.
1126#[derive(Clone, Debug, PartialEq)]
1127pub struct NeighborhoodRequest {
1128    pub root: NodeKey,
1129    pub depth: u8,
1130    pub node_budget: Option<u32>,
1131    pub include_phantoms: bool,
1132}
1133
1134/// Traversal / neighborhood response.
1135#[derive(Clone, Debug, PartialEq)]
1136pub struct TraversalResponse {
1137    pub nodes: Vec<NodeView>,
1138    pub edges: Vec<EdgeRef>,
1139    /// The seeds the walk actually started from: the requested keys, deduped,
1140    /// and with the ones the caller may not see removed.
1141    ///
1142    /// A caller cannot derive this from the request. Denied and unknown seeds
1143    /// are indistinguishable by contract, and both are simply absent, so a
1144    /// traversal from five keys that answers about three is otherwise silent
1145    /// about which three — and "seeds always survive truncation" is a promise
1146    /// with nothing to check it against.
1147    pub seeds: Vec<NodeKey>,
1148    pub truncated: Option<TruncationReason>,
1149    pub revision: GraphRevision,
1150    /// Whether every arm of this read observed one graph state.
1151    ///
1152    /// The contract asks for a repeatable-read snapshot across seed
1153    /// resolution, every hop and hydration. A store that cannot hold one
1154    /// declares `StoreCapabilities::snapshots = false`, and the service then
1155    /// brackets the walk with a revision read: unchanged means nothing
1156    /// committed while it ran and the answer is as good as a snapshot, while
1157    /// a moved revision means the arms may not agree with each other.
1158    ///
1159    /// Said out loud because the alternative is a `revision` field that names
1160    /// a state the response never existed at, which no consumer can detect
1161    /// and every revision-keyed cache would trust.
1162    pub consistent_snapshot: bool,
1163}
1164
1165// ---------------------------------------------------------------------------
1166// Labels (contract present, implementation deferred)
1167// ---------------------------------------------------------------------------
1168
1169#[derive(Clone, Debug, PartialEq)]
1170pub struct LabelSpec {
1171    pub name: String,
1172    pub description: Option<String>,
1173    pub style: Option<serde_json::Value>,
1174    pub applies_to: LabelAppliesTo,
1175}
1176
1177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1178pub enum LabelAppliesTo {
1179    Node,
1180    Edge,
1181    Both,
1182}
1183
1184#[derive(Clone, Debug, PartialEq)]
1185pub struct LabelRecord {
1186    pub id: LabelId,
1187    pub spec: LabelSpec,
1188    pub created_at: OffsetDateTime,
1189}
1190
1191#[derive(Clone, Debug, PartialEq, Eq)]
1192pub struct LabelAssignment {
1193    pub target: LabelTarget,
1194    pub attach: Vec<LabelId>,
1195    pub detach: Vec<LabelId>,
1196}
1197
1198#[derive(Clone, Debug, PartialEq, Eq)]
1199pub enum LabelTarget {
1200    Node(NodeKey),
1201    Edge(EdgeKey),
1202}
1203
1204/// Revision-only outcome for label mutations.
1205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1206pub struct RevisionOutcome {
1207    pub revision: GraphRevision,
1208}
1209
1210// ---------------------------------------------------------------------------
1211// Topology (analytics boundary; capability optional)
1212// ---------------------------------------------------------------------------
1213
1214#[derive(Clone, Debug, Default, PartialEq, Eq)]
1215pub struct TopologyRequest {
1216    pub cursor: Option<String>,
1217    pub page_size: Option<u32>,
1218}
1219
1220#[derive(Clone, Debug, PartialEq, Eq)]
1221pub struct TopologyPage {
1222    pub nodes: Vec<(NodeKey, GtsTypeId)>,
1223    pub edges: Vec<EdgeRef>,
1224    pub next_cursor: Option<String>,
1225    pub schema_version: u32,
1226}
1227
1228// ---------------------------------------------------------------------------
1229// Capabilities
1230// ---------------------------------------------------------------------------
1231
1232/// What a store implementation provides. Anything absent is answered
1233/// `Unsupported`, never approximated.
1234#[expect(
1235    clippy::struct_excessive_bools,
1236    reason = "a capability set is independent yes/no facts read by name, not a \
1237              parameter list; collapsing them into flags would hide which \
1238              capability a store lacks at the call site"
1239)]
1240#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1241pub struct StoreCapabilities {
1242    pub scope_replace: bool,
1243    pub snapshots: bool,
1244    pub vector_search: bool,
1245    pub labels: bool,
1246    pub chunks: bool,
1247    pub topology: bool,
1248}
1249
1250/// What an engine implementation provides beyond one-hop expansion.
1251#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1252pub struct EngineCapabilities {
1253    pub shortest_path: bool,
1254    pub match_pattern: bool,
1255}
1256
1257// ---------------------------------------------------------------------------
1258// Budget
1259// ---------------------------------------------------------------------------
1260
1261/// What is left of an operation's absolute deadline — never a fresh timeout,
1262/// so a slow earlier step shortens the next one rather than extending the
1263/// total.
1264#[derive(Clone, Copy, Debug)]
1265pub struct RemainingBudget {
1266    deadline: Instant,
1267}
1268
1269impl RemainingBudget {
1270    /// Open a budget expiring `total` from now.
1271    #[must_use]
1272    pub fn starting_now(total: Duration) -> Self {
1273        Self {
1274            deadline: Instant::now() + total,
1275        }
1276    }
1277
1278    #[must_use]
1279    pub fn remaining(&self) -> Duration {
1280        self.deadline.saturating_duration_since(Instant::now())
1281    }
1282
1283    #[must_use]
1284    pub fn is_exhausted(&self) -> bool {
1285        self.remaining().is_zero()
1286    }
1287}
1288
1289// ---------------------------------------------------------------------------
1290// Embedding space
1291// ---------------------------------------------------------------------------
1292
1293/// Full embedding-space identity. Two providers with the same dimension and
1294/// different identities produce incomparable vectors, so the identity is more
1295/// than a width.
1296///
1297/// The fields below are exactly the ones the `embedding_space` table records
1298/// (DESIGN § Table `embedding_space`), so a provider's declaration and the
1299/// durable row cannot describe different things.
1300#[derive(Clone, Debug, PartialEq, Eq)]
1301pub struct EmbeddingSpaceId {
1302    /// Canonical hash over the artifact/preprocessing identity below. Derived
1303    /// by [`EmbeddingSpaceId::new`] — never assembled by hand, or two
1304    /// providers describing one space would disagree about its name.
1305    pub identity_hash: String,
1306    /// Exact model artifact: name plus version or content hash.
1307    pub model_artifact: String,
1308    /// Exact tokenizer artifact, on the same terms.
1309    pub tokenizer_artifact: String,
1310    /// Declared preprocessing, pooling and normalization configuration. A
1311    /// different pooling rule over identical weights still yields vectors
1312    /// that must not be compared, so these are part of the identity rather
1313    /// than documentation of it.
1314    pub preprocessing: serde_json::Value,
1315    pub pooling: serde_json::Value,
1316    pub normalization: serde_json::Value,
1317    pub dimension: u32,
1318}
1319
1320impl EmbeddingSpaceId {
1321    /// Build an identity and derive its canonical hash.
1322    ///
1323    /// The hash lives here rather than in each provider because it is the name
1324    /// readiness compares against: the ONNX plugin, a remote plugin and the
1325    /// deterministic fake must all arrive at the same string for the same
1326    /// space, and at different strings for different ones.
1327    ///
1328    /// A provider's `preprocessing`, `pooling` and `normalization` blobs are
1329    /// frozen once it is released. Their shape is written in the provider's
1330    /// code, and the hash is over the blob as built (after key order and
1331    /// integral numbers are normalized), not over what it means: adding a
1332    /// key, renaming one or changing how a value is spelled gives every
1333    /// deployment of the new version a different identity, and the gear
1334    /// blocks vector search over what the old version stored until it is
1335    /// re-embedded. Values taken from configuration belong in a blob, since a
1336    /// different setting there is a different space; a change of shape needs
1337    /// the same deliberation as a change of model.
1338    #[must_use]
1339    pub fn new(
1340        model_artifact: impl Into<String>,
1341        tokenizer_artifact: impl Into<String>,
1342        preprocessing: serde_json::Value,
1343        pooling: serde_json::Value,
1344        normalization: serde_json::Value,
1345        dimension: u32,
1346    ) -> Self {
1347        let model_artifact = model_artifact.into();
1348        let tokenizer_artifact = tokenizer_artifact.into();
1349        let identity_hash = identity_hash(
1350            &model_artifact,
1351            &tokenizer_artifact,
1352            &preprocessing,
1353            &pooling,
1354            &normalization,
1355            dimension,
1356        );
1357        Self {
1358            identity_hash,
1359            model_artifact,
1360            tokenizer_artifact,
1361            preprocessing,
1362            pooling,
1363            normalization,
1364            dimension,
1365        }
1366    }
1367}
1368
1369/// One rendering per JSON value, for everything in this contract that
1370/// identifies something by hashing it.
1371///
1372/// Object keys are sorted, and a whole number is folded onto one spelling.
1373/// Both exist because the hash is taken over rendered text: `serde_json` keeps
1374/// the variant it parsed, so `1`, `1.0` and `1e0` arrive as `PosInt` and
1375/// `Float` and `Display` renders the variant rather than the value. Two
1376/// producers of the same logical configuration -- or two versions of one
1377/// producer's serializer -- would otherwise hash differently.
1378///
1379/// Shared rather than copied: the embedding-space identity and the ingest
1380/// request hash both do this, and the first version of this function lived in
1381/// two places and was fixed in one.
1382#[must_use]
1383pub fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
1384    match value {
1385        serde_json::Value::Object(map) => serde_json::Value::Object(
1386            map.iter()
1387                .map(|(key, inner)| (key.clone(), canonical_json(inner)))
1388                .collect::<std::collections::BTreeMap<_, _>>()
1389                .into_iter()
1390                .collect(),
1391        ),
1392        serde_json::Value::Array(items) => {
1393            serde_json::Value::Array(items.iter().map(canonical_json).collect())
1394        }
1395        serde_json::Value::Number(number) => serde_json::Value::Number(canonical_number(number)),
1396        other => other.clone(),
1397    }
1398}
1399
1400/// The largest magnitude an `f64` represents without gaps between consecutive
1401/// integers. Above it, a float's integral look says nothing about the integer
1402/// a producer meant, so the number is left exactly as it was parsed.
1403const EXACT_INTEGER_LIMIT: f64 = 9_007_199_254_740_992.0; // 2^53
1404
1405fn canonical_number(number: &serde_json::Number) -> serde_json::Number {
1406    if number.is_f64()
1407        && let Some(float) = number.as_f64()
1408        && float.fract() == 0.0
1409        && float.abs() < EXACT_INTEGER_LIMIT
1410    {
1411        // `fract() == 0.0` already excludes NaN and both infinities.
1412        #[expect(
1413            clippy::cast_possible_truncation,
1414            reason = "the magnitude bound above is exactly the range this cast is lossless over"
1415        )]
1416        return serde_json::Number::from(float as i64);
1417    }
1418    number.clone()
1419}
1420
1421fn identity_hash(
1422    model_artifact: &str,
1423    tokenizer_artifact: &str,
1424    preprocessing: &serde_json::Value,
1425    pooling: &serde_json::Value,
1426    normalization: &serde_json::Value,
1427    dimension: u32,
1428) -> String {
1429    // `aws-lc-rs` is the workspace's FIPS-capable backend; a pure-Rust hasher
1430    // is refused by the DE0708 lint. Field boundaries are length-prefixed so
1431    // no concatenation of distinct identities can collide.
1432    let mut hasher = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
1433    let preprocessing = canonical_json(preprocessing).to_string();
1434    let pooling = canonical_json(pooling).to_string();
1435    let normalization = canonical_json(normalization).to_string();
1436    for part in [
1437        model_artifact.as_bytes(),
1438        tokenizer_artifact.as_bytes(),
1439        preprocessing.as_bytes(),
1440        pooling.as_bytes(),
1441        normalization.as_bytes(),
1442        &dimension.to_be_bytes(),
1443    ] {
1444        hasher.update(&(part.len() as u64).to_be_bytes());
1445        hasher.update(part);
1446    }
1447    hex::encode(hasher.finish())
1448}
1449
1450#[cfg(test)]
1451mod embedding_space_tests {
1452    use super::EmbeddingSpaceId;
1453
1454    fn space(pooling: &str, dimension: u32) -> EmbeddingSpaceId {
1455        EmbeddingSpaceId::new(
1456            "all-MiniLM-L6-v2@sha256:abc",
1457            "bert-wordpiece@sha256:def",
1458            serde_json::json!({ "lowercase": true }),
1459            serde_json::json!({ "strategy": pooling }),
1460            serde_json::json!({ "l2": true }),
1461            dimension,
1462        )
1463    }
1464
1465    /// The other half of "the same configuration". Two providers describing
1466    /// one preprocessing step can write its numbers differently -- a config
1467    /// round-tripped through a float-based representation renders `1` as
1468    /// `1.0` -- and the identity is what readiness compares against the
1469    /// identity the stored vectors were produced under. A spelling difference
1470    /// there reports the embedding space `Unhealthy`, takes vector and hybrid
1471    /// search out of service, and sends the operator to a re-embedding
1472    /// lifecycle that would not have fixed anything.
1473    #[test]
1474    fn the_same_identity_hashes_the_same_however_its_numbers_are_written() {
1475        let configured = |preprocessing: &str| {
1476            EmbeddingSpaceId::new(
1477                "all-MiniLM-L6-v2@sha256:abc",
1478                "bert-wordpiece@sha256:def",
1479                serde_json::from_str(preprocessing).expect("the fixture is JSON"),
1480                serde_json::json!({ "strategy": "mean" }),
1481                serde_json::json!({ "l2": true }),
1482                384,
1483            )
1484        };
1485        assert_eq!(
1486            configured(r#"{"max_length": 512}"#).identity_hash,
1487            configured(r#"{"max_length": 512.0}"#).identity_hash
1488        );
1489        assert_ne!(
1490            configured(r#"{"max_length": 512}"#).identity_hash,
1491            configured(r#"{"max_length": 256}"#).identity_hash,
1492            "folding spellings together must not fold values together"
1493        );
1494    }
1495
1496    #[test]
1497    fn the_same_identity_hashes_the_same_however_the_json_is_ordered() {
1498        let one = EmbeddingSpaceId::new(
1499            "m",
1500            "t",
1501            serde_json::json!({ "a": 1, "b": 2 }),
1502            serde_json::json!({}),
1503            serde_json::json!({}),
1504            384,
1505        );
1506        let other = EmbeddingSpaceId::new(
1507            "m",
1508            "t",
1509            serde_json::json!({ "b": 2, "a": 1 }),
1510            serde_json::json!({}),
1511            serde_json::json!({}),
1512            384,
1513        );
1514        assert_eq!(one.identity_hash, other.identity_hash);
1515    }
1516
1517    /// The case ADR-0005 exists for: same weights, same width, different
1518    /// pooling — incomparable vectors that a dimension check cannot see.
1519    #[test]
1520    fn pooling_alone_changes_the_identity() {
1521        assert_ne!(
1522            space("mean", 384).identity_hash,
1523            space("cls", 384).identity_hash
1524        );
1525    }
1526
1527    #[test]
1528    fn dimension_alone_changes_the_identity() {
1529        assert_ne!(
1530            space("mean", 384).identity_hash,
1531            space("mean", 768).identity_hash
1532        );
1533    }
1534}
1535
1536#[cfg(test)]
1537mod closed_enum_tests {
1538    use super::*;
1539
1540    /// Rule 3 of the Closed Enum Contract, held by a test rather than by each
1541    /// call site remembering it.
1542    ///
1543    /// Every family round-trips through its one spelling, and an
1544    /// unrecognized value is refused by name rather than mapped onto a
1545    /// variant. The danger the rule exists for is a decoder acquiring a
1546    /// `_ =>` arm: an unknown outcome read as `unchanged`, or an unknown
1547    /// readiness state read as `healthy`, turns a value the server chose
1548    /// into one it did not.
1549    macro_rules! contract_case {
1550        ($case:ident, $name:ident, $label:literal, [$($variant:expr),+ $(,)?]) => {
1551            #[test]
1552            fn $case() {
1553                let mut seen: Vec<&'static str> = Vec::new();
1554                $(
1555                    let spelling = $variant.as_str();
1556                    assert!(
1557                        !seen.contains(&spelling),
1558                        "two {} variants share the spelling `{spelling}`",
1559                        $label
1560                    );
1561                    seen.push(spelling);
1562                    assert_eq!(
1563                        spelling.parse::<$name>().expect("its own spelling decodes"),
1564                        $variant,
1565                        "{} does not round-trip through `{spelling}`",
1566                        $label
1567                    );
1568                )+
1569                for unknown in ["", "UNKNOWN", "healthy_", " node", "something_new"] {
1570                    assert!(!seen.contains(&unknown), "the fixture must be unknown");
1571                    let refused = unknown
1572                        .parse::<$name>()
1573                        .expect_err("an unknown value is never a known variant");
1574                    assert_eq!(refused.found, unknown);
1575                    assert_eq!(refused.expected, $label);
1576                }
1577            }
1578        };
1579    }
1580
1581    contract_case!(
1582        a_type_kind,
1583        TypeKind,
1584        "type kind",
1585        [TypeKind::Node, TypeKind::Edge, TypeKind::Attribute]
1586    );
1587    contract_case!(
1588        a_readiness_state,
1589        ReadinessState,
1590        "readiness state",
1591        [
1592            ReadinessState::Healthy,
1593            ReadinessState::Degraded,
1594            ReadinessState::Unhealthy,
1595            ReadinessState::NotImplemented,
1596        ]
1597    );
1598    contract_case!(
1599        a_type_change_state,
1600        TypeChangeState,
1601        "type change state",
1602        [
1603            TypeChangeState::New,
1604            TypeChangeState::Unchanged,
1605            TypeChangeState::Compatible,
1606            TypeChangeState::Incompatible,
1607            TypeChangeState::Undecidable,
1608        ]
1609    );
1610    contract_case!(
1611        a_type_outcome,
1612        TypeOutcome,
1613        "type outcome",
1614        [
1615            TypeOutcome::Created,
1616            TypeOutcome::Unchanged,
1617            TypeOutcome::Updated
1618        ]
1619    );
1620    contract_case!(
1621        an_item_outcome,
1622        ItemOutcome,
1623        "item outcome",
1624        [
1625            ItemOutcome::Inserted,
1626            ItemOutcome::Updated,
1627            ItemOutcome::Unchanged,
1628            ItemOutcome::Materialized,
1629        ]
1630    );
1631    contract_case!(
1632        an_adjacency_side,
1633        AdjacencySide,
1634        "adjacency side",
1635        [AdjacencySide::Outgoing, AdjacencySide::Incoming]
1636    );
1637    contract_case!(
1638        a_search_arm,
1639        SearchArm,
1640        "search arm",
1641        [SearchArm::Lexical, SearchArm::Vector]
1642    );
1643    contract_case!(
1644        a_search_mode,
1645        SearchMode,
1646        "search mode",
1647        [SearchMode::Lexical, SearchMode::Vector, SearchMode::Hybrid]
1648    );
1649    contract_case!(
1650        a_direction,
1651        Direction,
1652        "direction",
1653        [Direction::Outgoing, Direction::Incoming, Direction::Either]
1654    );
1655    contract_case!(
1656        a_truncation_reason,
1657        TruncationReason,
1658        "truncation reason",
1659        [
1660            TruncationReason::FrontierCap,
1661            TruncationReason::EdgeScanCap,
1662            TruncationReason::NodeBudget,
1663            TruncationReason::ResponseBytes,
1664        ]
1665    );
1666}