Skip to main content

omnigraph_cluster/
types.rs

1//! Public output/diagnostic types and internal state/sidecar/approval
2//! models (moved verbatim from lib.rs in the modularization).
3
4use super::*;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum DiagnosticSeverity {
9    Error,
10    Warning,
11}
12
13#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
14pub struct Diagnostic {
15    pub code: String,
16    pub severity: DiagnosticSeverity,
17    pub path: String,
18    pub message: String,
19}
20
21impl Diagnostic {
22    pub(crate) fn error(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
23        Self {
24            code: code.into(),
25            severity: DiagnosticSeverity::Error,
26            path: path.into(),
27            message: message.into(),
28        }
29    }
30
31    pub(crate) fn warning(
32        code: impl Into<String>,
33        path: impl Into<String>,
34        message: impl Into<String>,
35    ) -> Self {
36        Self {
37            code: code.into(),
38            severity: DiagnosticSeverity::Warning,
39            path: path.into(),
40            message: message.into(),
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
46pub struct ResourceSummary {
47    pub address: String,
48    pub kind: String,
49    pub digest: String,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub path: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
55pub struct Dependency {
56    pub from: String,
57    pub to: String,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct ValidateOutput {
62    pub ok: bool,
63    pub config_dir: String,
64    pub config_file: String,
65    pub resource_digests: BTreeMap<String, String>,
66    pub resources: Vec<ResourceSummary>,
67    pub dependencies: Vec<Dependency>,
68    pub diagnostics: Vec<Diagnostic>,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct DesiredRevision {
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub config_digest: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct StateObservations {
79    pub state_path: String,
80    pub lock_path: String,
81    pub state_found: bool,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub applied_config_digest: Option<String>,
84    pub state_revision: u64,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub state_cas: Option<String>,
87    pub resource_count: usize,
88    pub locked: bool,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub lock_id: Option<String>,
91    pub lock_acquired: bool,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub acquired_lock_id: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub lock_operation: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub lock_created_at: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub lock_pid: Option<u32>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub lock_age_seconds: Option<u64>,
102}
103
104impl StateObservations {
105    pub(crate) fn observe_lock_metadata(&mut self, lock: &StateLockFile) {
106        self.locked = true;
107        self.lock_id = Some(lock.lock_id.clone());
108        self.lock_operation = Some(lock.operation.clone());
109        self.lock_created_at = Some(lock.created_at.clone());
110        self.lock_pid = Some(lock.pid);
111        self.lock_age_seconds = lock_age_seconds(&lock.created_at);
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum ResourceLifecycleStatus {
118    Pending,
119    Planned,
120    Applying,
121    Applied,
122    Drifted,
123    Blocked,
124    Error,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(deny_unknown_fields)]
129pub struct ResourceStatusRecord {
130    pub status: ResourceLifecycleStatus,
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub conditions: Vec<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub message: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum PlanOperation {
140    Create,
141    Update,
142    Delete,
143}
144
145/// How `cluster apply` treats a planned change in the current stage.
146///
147/// `Applied` changes execute (config-only query/policy catalog writes).
148/// `Derived` marks a `graph.<id>` composite-digest update that converges
149/// automatically once its applied query digests land in state. `Deferred`
150/// changes need a later phase (graph/schema lifecycle or schema content).
151/// `Blocked` query/policy changes are gated by an unapplied or missing
152/// dependency.
153#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
154#[serde(rename_all = "snake_case")]
155pub enum ApplyDisposition {
156    Applied,
157    Derived,
158    Deferred,
159    Blocked,
160}
161
162#[derive(Debug, Clone, Serialize, PartialEq)]
163pub struct PlanChange {
164    pub resource: String,
165    pub operation: PlanOperation,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub before_digest: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub after_digest: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub disposition: Option<ApplyDisposition>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub reason: Option<String>,
174    /// True for a policy change whose file digest is unchanged but whose
175    /// `applies_to` bindings differ from the applied revision (including the
176    /// pre-5A backfill case).
177    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178    pub binding_change: bool,
179    /// Metadata-only updates whose resource content digest is unchanged but
180    /// whose applied ledger metadata needs to converge.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub metadata_change: Option<PlanMetadataChange>,
183    /// For schema updates: the engine's migration plan against the live
184    /// graph (RFC-004 §D7's data-aware preview). Absent when the preview is
185    /// unavailable (warning `schema_preview_unavailable`).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub migration: Option<SchemaMigrationPlan>,
188}
189
190#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
191#[serde(rename_all = "snake_case")]
192pub enum PlanMetadataChange {
193    PolicyBindings,
194    EmbeddingProfile,
195}
196
197#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
198pub struct BlastRadius {
199    pub resource: String,
200    pub affected: Vec<String>,
201}
202
203#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
204pub struct ApprovalRequirement {
205    pub resource: String,
206    pub reason: String,
207    /// True when a valid (digest-matching, unconsumed) approval artifact is
208    /// pending for this change.
209    pub satisfied: bool,
210}
211
212#[derive(Debug, Clone, Serialize)]
213pub struct PlanOutput {
214    pub ok: bool,
215    pub config_dir: String,
216    pub desired_revision: DesiredRevision,
217    pub resource_digests: BTreeMap<String, String>,
218    pub dependencies: Vec<Dependency>,
219    pub state_observations: StateObservations,
220    pub changes: Vec<PlanChange>,
221    pub blast_radius: Vec<BlastRadius>,
222    pub approvals_required: Vec<ApprovalRequirement>,
223    pub diagnostics: Vec<Diagnostic>,
224}
225
226#[derive(Debug, Clone, Serialize)]
227pub struct StatusOutput {
228    pub ok: bool,
229    pub config_dir: String,
230    pub state_observations: StateObservations,
231    pub resource_digests: BTreeMap<String, String>,
232    pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
233    pub observations: BTreeMap<String, serde_json::Value>,
234    pub diagnostics: Vec<Diagnostic>,
235}
236
237#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum StateSyncOperation {
240    Refresh,
241    Import,
242}
243
244#[derive(Debug, Clone, Serialize)]
245pub struct StateSyncOutput {
246    pub ok: bool,
247    pub operation: StateSyncOperation,
248    pub config_dir: String,
249    pub state_observations: StateObservations,
250    pub resource_digests: BTreeMap<String, String>,
251    pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
252    pub observations: BTreeMap<String, serde_json::Value>,
253    pub diagnostics: Vec<Diagnostic>,
254}
255
256#[derive(Debug, Clone, Serialize)]
257pub struct ForceUnlockOutput {
258    pub ok: bool,
259    pub config_dir: String,
260    pub state_observations: StateObservations,
261    pub lock_removed: bool,
262    pub diagnostics: Vec<Diagnostic>,
263}
264
265/// Output of config-only `cluster apply`. "Applied" means recorded in the
266/// local cluster catalog (`__cluster/`); nothing applied here serves traffic —
267/// the server still boots from `omnigraph.yaml` until the server-boot stage.
268#[derive(Debug, Clone, Serialize)]
269pub struct ApplyOutput {
270    pub ok: bool,
271    pub config_dir: String,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub actor: Option<String>,
274    pub desired_revision: DesiredRevision,
275    pub state_observations: StateObservations,
276    /// Every planned change, with `disposition`/`reason` always populated.
277    pub changes: Vec<PlanChange>,
278    pub applied_count: usize,
279    /// Deferred + Blocked changes (Derived composite updates count as neither).
280    pub deferred_count: usize,
281    /// True when state matches the desired revision after this apply.
282    pub converged: bool,
283    /// False for a no-op re-apply: state bytes (and revision) were left untouched.
284    pub state_written: bool,
285    /// The statuses as persisted: post-apply on success, the pre-apply on-disk
286    /// snapshot when the state write fails (never unpersisted in-memory state).
287    pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
288    pub diagnostics: Vec<Diagnostic>,
289}
290
291/// A digest-bound human approval for an irreversible operation (RFC-004
292/// §D4). Written by `cluster approve`, consumed by apply. The file is never
293/// deleted on consumption — it is rewritten with `consumed_at` and also
294/// summarized into the state ledger's `approval_records`, so the audit fact
295/// survives the loss of either store (axiom 11).
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub(crate) struct ApprovalArtifact {
299    pub(crate) schema_version: u32,
300    pub(crate) approval_id: String,
301    pub(crate) resource: String,
302    pub(crate) operation: String,
303    pub(crate) reason: String,
304    pub(crate) bound_config_digest: String,
305    #[serde(default)]
306    pub(crate) bound_before_digest: Option<String>,
307    #[serde(default)]
308    pub(crate) bound_after_digest: Option<String>,
309    pub(crate) approved_by: String,
310    pub(crate) created_at: String,
311    #[serde(default)]
312    pub(crate) consumed_at: Option<String>,
313    #[serde(default)]
314    pub(crate) consumed_by_operation: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize)]
318pub struct ApproveOutput {
319    pub ok: bool,
320    pub config_dir: String,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub approval_id: Option<String>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub resource: Option<String>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub operation: Option<PlanOperation>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub approved_by: Option<String>,
329    pub diagnostics: Vec<Diagnostic>,
330}
331
332#[derive(Debug, Clone)]
333pub(crate) struct DesiredCluster {
334    pub(crate) config_dir: PathBuf,
335    pub(crate) config_digest: String,
336    /// The declared `storage:` root, if any (None ⇒ the config dir itself).
337    pub(crate) storage_root: Option<String>,
338    pub(crate) state_lock: bool,
339    pub(crate) embedding_providers: BTreeMap<String, EmbeddingProviderConfig>,
340    pub(crate) graphs: Vec<DesiredGraph>,
341    pub(crate) resource_digests: BTreeMap<String, String>,
342    pub(crate) resources: Vec<ResourceSummary>,
343    pub(crate) dependencies: Vec<Dependency>,
344    /// `policy.<name>` address -> normalized applies_to refs.
345    pub(crate) policy_bindings: BTreeMap<String, Vec<String>>,
346}
347
348#[derive(Debug, Clone)]
349pub(crate) struct DesiredGraph {
350    pub(crate) id: String,
351    pub(crate) schema_digest: String,
352    pub(crate) embedding_provider: Option<String>,
353}
354
355#[derive(Debug)]
356pub(crate) struct ParsedConfig {
357    pub(crate) raw: Option<RawClusterConfig>,
358    pub(crate) diagnostics: Vec<Diagnostic>,
359    pub(crate) config_dir: PathBuf,
360    pub(crate) config_file: PathBuf,
361}
362
363#[derive(Debug, Clone)]
364pub(crate) struct ClusterSettings {
365    pub(crate) state_lock: bool,
366    pub(crate) storage_root: Option<String>,
367}
368
369#[derive(Debug)]
370pub(crate) struct LoadOutcome {
371    pub(crate) desired: Option<DesiredCluster>,
372    pub(crate) diagnostics: Vec<Diagnostic>,
373    pub(crate) config_dir: PathBuf,
374    pub(crate) config_file: PathBuf,
375}
376
377#[derive(Debug, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub(crate) struct RawClusterConfig {
380    pub(crate) version: u32,
381    #[serde(default)]
382    pub(crate) metadata: Metadata,
383    /// Storage root URI for everything the cluster stores: the state
384    /// ledger, catalog, sidecars, approvals, and derived graph roots.
385    /// Absent ⇒ `file://<config-dir>` (the original layout, byte-compatible).
386    /// `s3://bucket/prefix` puts the whole cluster on object storage.
387    #[serde(default)]
388    pub(crate) storage: Option<String>,
389    #[serde(default)]
390    pub(crate) state: StateConfig,
391    #[serde(default)]
392    pub(crate) providers: ProvidersConfig,
393    #[serde(default)]
394    pub(crate) graphs: BTreeMap<String, GraphConfig>,
395    #[serde(default)]
396    pub(crate) policies: BTreeMap<String, PolicyConfig>,
397}
398
399#[derive(Debug, Default, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub(crate) struct Metadata {
402    pub(crate) name: Option<String>,
403}
404
405#[derive(Debug, Default, Serialize, Deserialize)]
406#[serde(deny_unknown_fields)]
407pub(crate) struct StateConfig {
408    pub(crate) backend: Option<String>,
409    pub(crate) lock: Option<bool>,
410}
411
412#[derive(Debug, Default, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub(crate) struct ProvidersConfig {
415    #[serde(default)]
416    pub(crate) embedding: BTreeMap<String, EmbeddingProviderConfig>,
417}
418
419#[derive(Debug, Serialize, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub(crate) struct GraphConfig {
422    pub(crate) schema: PathBuf,
423    #[serde(default)]
424    pub(crate) queries: QueriesDecl,
425    /// Optional reference to a top-level `providers.embedding.<name>` profile.
426    #[serde(default)]
427    pub(crate) embedding_provider: Option<String>,
428}
429
430/// A named cluster embedding provider profile (RFC-012 Phase 5). `kind`/`base_url`/
431/// `model` default exactly as the engine's `EmbeddingConfig::from_env` does.
432/// `api_key`, when required, must be a `${NAME}` env reference resolved at
433/// serving boot, never an inline secret.
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
435#[serde(deny_unknown_fields)]
436pub struct EmbeddingProviderConfig {
437    #[serde(default, alias = "provider", skip_serializing_if = "Option::is_none")]
438    pub kind: Option<String>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub base_url: Option<String>,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub model: Option<String>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub api_key: Option<String>,
445}
446
447impl EmbeddingProviderConfig {
448    pub(crate) fn validate(&self, path: String, diagnostics: &mut Vec<Diagnostic>) {
449        if let Err(error) = omnigraph::embedding::EmbeddingConfig::from_parts(
450            self.kind.as_deref(),
451            self.base_url.clone(),
452            self.model.clone(),
453            "validation-placeholder".to_string(),
454        ) {
455            diagnostics.push(Diagnostic::error(
456                "invalid_embedding_provider",
457                path.clone(),
458                error.to_string(),
459            ));
460        }
461
462        if self.kind.as_deref() == Some("mock") {
463            if let Some(api_key) = self.api_key.as_deref() {
464                if secret_ref_name(api_key).is_err() {
465                    diagnostics.push(Diagnostic::error(
466                        "embedding_api_key_inline",
467                        format!("{path}.api_key"),
468                        "embedding api_key must be a ${NAME} env reference, not an inline secret",
469                    ));
470                }
471            }
472            return;
473        }
474
475        match self.api_key.as_deref() {
476            Some(api_key) if secret_ref_name(api_key).is_err() => diagnostics.push(
477                Diagnostic::error(
478                    "embedding_api_key_inline",
479                    format!("{path}.api_key"),
480                    "embedding api_key must be a ${NAME} env reference, not an inline secret",
481                ),
482            ),
483            Some(_) => {}
484            None => diagnostics.push(Diagnostic::error(
485                "embedding_api_key_required",
486                format!("{path}.api_key"),
487                "non-mock embedding providers must set api_key to a ${NAME} env reference",
488            )),
489        }
490    }
491
492    /// Resolve into an engine `EmbeddingConfig`, reading the `${NAME}` api-key
493    /// reference from process env. Mock profiles do not read env and may omit
494    /// `api_key`; real providers error if the reference is missing or unset.
495    pub fn resolve(&self) -> Result<omnigraph::embedding::EmbeddingConfig, String> {
496        let api_key = if self.kind.as_deref() == Some("mock") {
497            String::new()
498        } else {
499            resolve_secret_ref(self.api_key.as_deref().ok_or_else(|| {
500                "embedding api_key is required for non-mock providers".to_string()
501            })?)?
502        };
503        omnigraph::embedding::EmbeddingConfig::from_parts(
504            self.kind.as_deref(),
505            self.base_url.clone(),
506            self.model.clone(),
507            api_key,
508        )
509        .map_err(|e| e.to_string())
510    }
511}
512
513fn secret_ref_name(value: &str) -> Result<&str, String> {
514    value
515        .trim()
516        .strip_prefix("${")
517        .and_then(|s| s.strip_suffix('}'))
518        .filter(|name| !name.trim().is_empty())
519        .ok_or_else(|| {
520            format!("embedding api_key must be a ${{NAME}} env reference, got '{}'", value.trim())
521        })
522}
523
524/// Resolve a `${NAME}` secret reference from process env. Rejects an inline value
525/// (anything not wrapped in `${…}`) so secrets never sit in the cluster config.
526fn resolve_secret_ref(value: &str) -> Result<String, String> {
527    let name = secret_ref_name(value)?;
528    std::env::var(name).map_err(|_| format!("embedding api_key env var '{name}' is not set"))
529}
530
531/// How a graph declares its stored queries. Terraform-style: the `.gq`
532/// files ARE the declaration — point at them (or a directory) and every
533#[derive(Debug, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub(crate) struct QueryConfig {
536    pub(crate) file: PathBuf,
537}
538
539#[derive(Debug, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub(crate) struct PolicyConfig {
542    pub(crate) file: PathBuf,
543    pub(crate) applies_to: Vec<String>,
544}
545
546// Stage 2A/2B accept these forward-compatible state sections so existing
547// ledgers won't churn while approval/recovery semantics are staged later.
548#[allow(dead_code)]
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub(crate) struct ClusterState {
552    pub(crate) version: u32,
553    #[serde(default)]
554    pub(crate) state_revision: u64,
555    pub(crate) applied_revision: AppliedRevisionState,
556    #[serde(default)]
557    pub(crate) resource_statuses: BTreeMap<String, ResourceStatusRecord>,
558    #[serde(default)]
559    pub(crate) approval_records: BTreeMap<String, serde_json::Value>,
560    #[serde(default)]
561    pub(crate) recovery_records: BTreeMap<String, serde_json::Value>,
562    #[serde(default)]
563    pub(crate) observations: BTreeMap<String, serde_json::Value>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
567#[serde(deny_unknown_fields)]
568pub(crate) struct AppliedRevisionState {
569    #[serde(default)]
570    pub(crate) config_digest: Option<String>,
571    #[serde(default)]
572    pub(crate) resources: BTreeMap<String, StateResource>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub(crate) struct StateResource {
578    pub(crate) digest: String,
579    /// Policy resources only: the applied `applies_to` bindings, normalized
580    /// to typed refs (`cluster` | `graph.<id>`). Recorded so the state
581    /// ledger is serving-sufficient for the Phase-5 server boot (RFC-005
582    /// §D3). Absent on pre-5A entries (backfilled by the next apply) and on
583    /// non-policy resources.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub(crate) applies_to: Option<Vec<String>>,
586    /// Graph resources only: the applied `provider.embedding.<name>` binding.
587    /// The provider profile itself is stored on the provider resource so
588    /// serving can boot without re-reading mutable desired config.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub(crate) embedding_provider: Option<String>,
591    /// Embedding provider resources only: the applied profile with unresolved
592    /// `${ENV}` references. The server resolves the referenced env var exactly
593    /// once at boot and injects the resulting engine config into the graph.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub(crate) embedding_profile: Option<EmbeddingProviderConfig>,
596}
597
598#[derive(Debug, Serialize, Deserialize)]
599#[serde(deny_unknown_fields)]
600pub(crate) struct StateLockFile {
601    pub(crate) version: u32,
602    pub(crate) lock_id: String,
603    pub(crate) operation: String,
604    pub(crate) created_at: String,
605    pub(crate) pid: u32,
606}
607
608/// Recovery-intent record for a graph-moving apply operation (RFC-004 §D2).
609/// Written under the state lock before the engine call that can create or
610/// move a graph manifest; deleted only after the cluster state CAS that
611/// records the outcome lands. The sweep (§D3) classifies survivors.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub(crate) struct RecoverySidecar {
615    pub(crate) schema_version: u32,
616    pub(crate) operation_id: String,
617    pub(crate) started_at: String,
618    #[serde(default)]
619    pub(crate) actor: Option<String>,
620    pub(crate) kind: RecoverySidecarKind,
621    pub(crate) graph_id: String,
622    pub(crate) graph_uri: String,
623    #[serde(default)]
624    pub(crate) observed_manifest_version: Option<u64>,
625    #[serde(default)]
626    pub(crate) expected_manifest_version: Option<u64>,
627    pub(crate) desired_schema_digest: String,
628    #[serde(default)]
629    pub(crate) state_cas_base: Option<String>,
630    /// For graph_delete: the approval this operation consumes; lets a sweep
631    /// roll-forward consume it too.
632    #[serde(default)]
633    pub(crate) approval_id: Option<String>,
634}
635
636#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
637#[serde(rename_all = "snake_case")]
638pub(crate) enum RecoverySidecarKind {
639    GraphCreate,
640    SchemaApply,
641    GraphDelete,
642}
643
644#[derive(Debug, Default)]
645pub(crate) struct SweepOutcome {
646    /// Graphs whose sidecar was kept (rows 5/6): graph-moving work for them
647    /// is blocked until the operator repairs and re-observes.
648    pub(crate) pending_graphs: BTreeSet<String>,
649    /// Sidecars whose outcome is recorded (rows 2/4): deleted only after the
650    /// command's state write lands, so a CAS failure re-sweeps them.
651    /// Store URIs (the storage layer addresses everything by URI).
652    pub(crate) completed_sidecars: Vec<String>,
653    /// Approval artifacts consumed by a roll-forward (delete row 7b): their
654    /// files are rewritten with consumed_at only after the state write lands.
655    pub(crate) consumed_approvals: Vec<String>,
656}
657
658#[cfg(test)]
659mod embedding_provider_config_tests {
660    use super::EmbeddingProviderConfig;
661
662    #[test]
663    fn resolves_secret_from_env_and_applies_defaults() {
664        // SAFETY: a unique var name, no concurrent reader.
665        unsafe { std::env::set_var("OG_TEST_EMBED_KEY_A", "secret-x") };
666        let profile = EmbeddingProviderConfig {
667            kind: Some("openai-compatible".to_string()),
668            base_url: None,
669            model: Some("m".to_string()),
670            api_key: Some("${OG_TEST_EMBED_KEY_A}".to_string()),
671        };
672        let config = profile.resolve().unwrap();
673        assert_eq!(config.api_key, "secret-x");
674        assert_eq!(config.model, "m");
675        unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_A") };
676    }
677
678    #[test]
679    fn rejects_inline_api_key() {
680        let profile = EmbeddingProviderConfig {
681            kind: None,
682            base_url: None,
683            model: None,
684            api_key: Some("sk-inline".to_string()),
685        };
686        let err = profile.resolve().unwrap_err();
687        assert!(err.contains("${NAME}"), "got: {err}");
688    }
689
690    #[test]
691    fn errors_on_unset_secret() {
692        let profile = EmbeddingProviderConfig {
693            kind: None,
694            base_url: None,
695            model: None,
696            api_key: Some("${OG_TEST_DEFINITELY_UNSET_VAR}".to_string()),
697        };
698        let err = profile.resolve().unwrap_err();
699        assert!(err.contains("not set"), "got: {err}");
700    }
701
702    #[test]
703    fn rejects_unknown_provider() {
704        unsafe { std::env::set_var("OG_TEST_EMBED_KEY_B", "x") };
705        let profile = EmbeddingProviderConfig {
706            kind: Some("cohere".to_string()),
707            base_url: None,
708            model: None,
709            api_key: Some("${OG_TEST_EMBED_KEY_B}".to_string()),
710        };
711        let err = profile.resolve().unwrap_err();
712        assert!(err.contains("unknown embedding provider"), "got: {err}");
713        unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_B") };
714    }
715
716    #[test]
717    fn mock_does_not_require_secret_env() {
718        let profile = EmbeddingProviderConfig {
719            kind: Some("mock".to_string()),
720            base_url: None,
721            model: Some("cluster-mock".to_string()),
722            api_key: None,
723        };
724        let config = profile.resolve().unwrap();
725        assert_eq!(config.model, "cluster-mock");
726    }
727}