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}