Skip to main content

zlayer_types/
storage.rs

1//! Storage `Stored*` wire types.
2//!
3//! These are the serde-friendly DTOs persisted by the daemon's `SqlxStorage`
4//! backends and surfaced over the REST API. They live here (not in
5//! `zlayer-api`) so SDK consumers can deserialize them without pulling in
6//! axum/sqlx/tokio.
7//!
8//! Convenience constructors that allocate fresh UUIDs, plus the
9//! database-bound traits and concrete sqlx implementations, remain in
10//! `zlayer-api::storage` — that's where the `uuid` dependency lives. This
11//! crate only carries the wire shapes (structs, enums, and pure-data
12//! `Display` impls).
13
14use std::collections::HashMap;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20use crate::spec::DeploymentSpec;
21
22// =========================================================================
23// Deployments
24// =========================================================================
25
26/// A stored deployment with metadata.
27#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
28pub struct StoredDeployment {
29    /// Deployment name (unique identifier)
30    pub name: String,
31
32    /// The deployment specification (complex nested structure, see spec docs)
33    #[schema(value_type = Object)]
34    pub spec: DeploymentSpec,
35
36    /// Current deployment status
37    pub status: DeploymentStatus,
38
39    /// When the deployment was created
40    #[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
41    pub created_at: DateTime<Utc>,
42
43    /// When the deployment was last updated
44    #[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
45    pub updated_at: DateTime<Utc>,
46
47    /// Last successfully-resolved image digest per service, keyed by service
48    /// name (e.g. `"web" -> "sha256:abc…"`).
49    ///
50    /// A deployment can carry multiple services, each with its own image, so
51    /// the digest is keyed by service rather than stored as a single value.
52    /// This records the digest a service actually pulled so that on daemon
53    /// restart a service whose image layers are already on local disk can be
54    /// recreated directly from those layers — pinning the exact digest —
55    /// without any remote registry or S3 traffic. That breaks the boot-time
56    /// circular dependency where coming up requires re-pulling an image that
57    /// is already present.
58    ///
59    /// `#[serde(default)]` keeps existing stored rows (written before this
60    /// field existed) deserializable; `skip_serializing_if` keeps rows that
61    /// never resolved a digest from being bloated with an empty map.
62    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
63    pub resolved_image_digests: HashMap<String, String>,
64}
65
66impl StoredDeployment {
67    /// Create a new stored deployment from a spec.
68    #[must_use]
69    pub fn new(spec: DeploymentSpec) -> Self {
70        let now = Utc::now();
71        Self {
72            name: spec.deployment.clone(),
73            spec,
74            status: DeploymentStatus::Pending,
75            created_at: now,
76            updated_at: now,
77            resolved_image_digests: HashMap::new(),
78        }
79    }
80
81    /// Update the deployment spec and timestamp.
82    pub fn update_spec(&mut self, spec: DeploymentSpec) {
83        self.spec = spec;
84        self.updated_at = Utc::now();
85    }
86
87    /// Update the deployment status and timestamp.
88    pub fn update_status(&mut self, status: DeploymentStatus) {
89        self.status = status;
90        self.updated_at = Utc::now();
91    }
92}
93
94/// Deployment lifecycle status.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
96#[serde(tag = "state", rename_all = "snake_case")]
97pub enum DeploymentStatus {
98    /// Deployment created but not yet started
99    Pending,
100
101    /// Deployment is being rolled out
102    Deploying,
103
104    /// All services are running
105    Running,
106
107    /// Deployment failed with an error message
108    Failed {
109        /// Error message describing the failure
110        message: String,
111    },
112
113    /// Deployment has been stopped
114    Stopped,
115}
116
117impl std::fmt::Display for DeploymentStatus {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            DeploymentStatus::Pending => write!(f, "pending"),
121            DeploymentStatus::Deploying => write!(f, "deploying"),
122            DeploymentStatus::Running => write!(f, "running"),
123            DeploymentStatus::Failed { message } => write!(f, "failed: {message}"),
124            DeploymentStatus::Stopped => write!(f, "stopped"),
125        }
126    }
127}
128
129// =========================================================================
130// Users
131// =========================================================================
132
133/// A stored user account.
134///
135/// The password hash lives in `zlayer-secrets::CredentialStore` keyed by the
136/// email address — NOT in this record. This type only carries user metadata.
137#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
138pub struct StoredUser {
139    /// Opaque user ID (`UUIDv4` string).
140    pub id: String,
141
142    /// Primary login identifier. Stored lower-cased.
143    pub email: String,
144
145    /// Human-readable display name.
146    pub display_name: String,
147
148    /// Role — "admin" or "user".
149    pub role: UserRole,
150
151    /// Whether the user can log in.
152    pub is_active: bool,
153
154    /// When the user was created.
155    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
156    pub created_at: DateTime<Utc>,
157
158    /// When the user was last updated.
159    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
160    pub updated_at: DateTime<Utc>,
161
162    /// When the user last logged in (if ever).
163    #[schema(value_type = Option<String>, example = "2026-04-15T12:00:00Z")]
164    pub last_login_at: Option<DateTime<Utc>>,
165}
166
167impl StoredUser {
168    /// Create a new user record with a fresh UUID and `is_active = true`.
169    #[must_use]
170    pub fn new(email: impl Into<String>, display_name: impl Into<String>, role: UserRole) -> Self {
171        let now = Utc::now();
172        Self {
173            id: Uuid::new_v4().to_string(),
174            email: email.into().to_lowercase(),
175            display_name: display_name.into(),
176            role,
177            is_active: true,
178            created_at: now,
179            updated_at: now,
180            last_login_at: None,
181        }
182    }
183
184    /// Record a successful login, advancing `last_login_at` and `updated_at`.
185    pub fn touch_login(&mut self) {
186        let now = Utc::now();
187        self.last_login_at = Some(now);
188        self.updated_at = now;
189    }
190}
191
192/// User role. Admins can do everything; regular users are constrained by
193/// per-resource permissions (added in a later phase).
194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
195#[serde(rename_all = "snake_case")]
196pub enum UserRole {
197    /// Full administrative access.
198    Admin,
199    /// Standard user constrained by per-resource permissions.
200    User,
201}
202
203impl UserRole {
204    /// Stable string form of this role.
205    #[must_use]
206    pub fn as_str(self) -> &'static str {
207        match self {
208            UserRole::Admin => "admin",
209            UserRole::User => "user",
210        }
211    }
212}
213
214impl std::fmt::Display for UserRole {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.write_str(self.as_str())
217    }
218}
219
220// =========================================================================
221// Environments
222// =========================================================================
223
224/// A deployment/runtime environment (e.g. "dev", "staging", "prod").
225///
226/// Each environment is an isolated namespace for secrets and, later,
227/// deployments. Optionally belongs to a `Project` (added in Phase 5) — when
228/// `project_id` is `None`, the environment is global.
229#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
230pub struct StoredEnvironment {
231    /// UUID identifier.
232    pub id: String,
233
234    /// Display name (e.g. "dev"). Unique within a given `project_id`.
235    pub name: String,
236
237    /// Project id this environment belongs to. `None` = global.
238    pub project_id: Option<String>,
239
240    /// Free-form description shown in the UI.
241    pub description: Option<String>,
242
243    /// When the environment was created.
244    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
245    pub created_at: DateTime<Utc>,
246
247    /// When the environment was last updated.
248    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
249    pub updated_at: DateTime<Utc>,
250}
251
252impl StoredEnvironment {
253    /// Create a new environment record with a fresh UUID.
254    #[must_use]
255    pub fn new(name: impl Into<String>, project_id: Option<String>) -> Self {
256        let now = Utc::now();
257        Self {
258            id: Uuid::new_v4().to_string(),
259            name: name.into(),
260            project_id,
261            description: None,
262            created_at: now,
263            updated_at: now,
264        }
265    }
266}
267
268// =========================================================================
269// Projects
270// =========================================================================
271
272/// A project bundles a git source, build configuration, registry credential
273/// reference, linked deployments, and a default environment.
274#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
275pub struct StoredProject {
276    /// UUID identifier.
277    pub id: String,
278
279    /// Project name (globally unique).
280    pub name: String,
281
282    /// Free-form description shown in the UI.
283    pub description: Option<String>,
284
285    /// Git repository URL (e.g. `"https://github.com/user/repo"`).
286    pub git_url: Option<String>,
287
288    /// Git branch to build from (default: `"main"`).
289    pub git_branch: Option<String>,
290
291    /// Reference to a `GitCredential` (Phase 5.2).
292    pub git_credential_id: Option<String>,
293
294    /// How the project is built.
295    pub build_kind: Option<BuildKind>,
296
297    /// Relative path within the repo (e.g. `"./Dockerfile"`).
298    pub build_path: Option<String>,
299
300    /// Relative path (inside the cloned repo) to a `DeploymentSpec` YAML that
301    /// the workflow `DeployProject` action should apply.
302    ///
303    /// When `None`, the workflow `DeployProject` action fails with a clear
304    /// "no deploy spec configured" error rather than silently succeeding —
305    /// callers are expected to set this explicitly via `project edit`.
306    #[serde(default)]
307    pub deploy_spec_path: Option<String>,
308
309    /// Reference to a `RegistryCredential` (Phase 5.2).
310    pub registry_credential_id: Option<String>,
311
312    /// Reference to the default environment for this project.
313    pub default_environment_id: Option<String>,
314
315    /// Reference to the owning user.
316    pub owner_id: Option<String>,
317
318    /// Whether new commits on the tracked branch should automatically
319    /// trigger a build + deploy cycle.
320    #[serde(default)]
321    pub auto_deploy: bool,
322
323    /// If set, the daemon polls the remote for new commits every N seconds.
324    /// `None` disables polling (the project is only updated via manual pull
325    /// or webhook).
326    #[serde(default)]
327    pub poll_interval_secs: Option<u64>,
328
329    /// When the project was created.
330    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
331    pub created_at: DateTime<Utc>,
332
333    /// When the project was last updated.
334    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
335    pub updated_at: DateTime<Utc>,
336}
337
338impl StoredProject {
339    /// Create a new project record with a fresh UUID.
340    #[must_use]
341    pub fn new(name: impl Into<String>) -> Self {
342        let now = Utc::now();
343        Self {
344            id: Uuid::new_v4().to_string(),
345            name: name.into(),
346            description: None,
347            git_url: None,
348            git_branch: Some("main".to_string()),
349            git_credential_id: None,
350            build_kind: None,
351            build_path: None,
352            deploy_spec_path: None,
353            registry_credential_id: None,
354            default_environment_id: None,
355            owner_id: None,
356            auto_deploy: false,
357            poll_interval_secs: None,
358            created_at: now,
359            updated_at: now,
360        }
361    }
362}
363
364/// How a project is built.
365#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
366#[serde(rename_all = "snake_case")]
367pub enum BuildKind {
368    /// Standard Dockerfile build.
369    Dockerfile,
370    /// Docker Compose / Compose file.
371    Compose,
372    /// `ZLayer`-native `ZImagefile`.
373    ZImagefile,
374    /// `ZLayer` deployment spec.
375    Spec,
376}
377
378impl std::fmt::Display for BuildKind {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        match self {
381            BuildKind::Dockerfile => f.write_str("dockerfile"),
382            BuildKind::Compose => f.write_str("compose"),
383            BuildKind::ZImagefile => f.write_str("zimagefile"),
384            BuildKind::Spec => f.write_str("spec"),
385        }
386    }
387}
388
389// =========================================================================
390// Variables
391// =========================================================================
392
393/// A stored variable — a plaintext key-value pair for template substitution
394/// in deployment specs. Variables are NOT encrypted (unlike secrets). They
395/// live in their own storage, separate from the encrypted secrets store.
396///
397/// Variables can be global (`scope = None`) or project-scoped
398/// (`scope = Some(project_id)`).
399#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
400pub struct StoredVariable {
401    /// UUID identifier.
402    pub id: String,
403
404    /// Variable name (e.g. `"APP_VERSION"`, `"LOG_LEVEL"`). Unique within a
405    /// given scope.
406    pub name: String,
407
408    /// Plaintext variable value.
409    pub value: String,
410
411    /// Scope: project id or `None` for global.
412    pub scope: Option<String>,
413
414    /// When the variable was created.
415    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
416    pub created_at: DateTime<Utc>,
417
418    /// When the variable was last updated.
419    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
420    pub updated_at: DateTime<Utc>,
421}
422
423impl StoredVariable {
424    /// Create a new variable record with a fresh UUID.
425    #[must_use]
426    pub fn new(name: impl Into<String>, value: impl Into<String>, scope: Option<String>) -> Self {
427        let now = Utc::now();
428        Self {
429            id: Uuid::new_v4().to_string(),
430            name: name.into(),
431            value: value.into(),
432            scope,
433            created_at: now,
434            updated_at: now,
435        }
436    }
437}
438
439// =========================================================================
440// Syncs
441// =========================================================================
442
443/// A stored sync resource (persistent record of a git-backed resource set).
444///
445/// A sync points at a directory within a project's checkout that contains
446/// `ZLayer` resource YAMLs. On diff/apply the directory is scanned, compared
447/// against current API state, and reconciled.
448#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
449pub struct StoredSync {
450    /// UUID identifier.
451    pub id: String,
452
453    /// Display name for this sync.
454    pub name: String,
455
456    /// Linked project id (the git checkout to scan).
457    pub project_id: Option<String>,
458
459    /// Path within the project's checkout to scan for resource YAMLs.
460    pub git_path: String,
461
462    /// Whether the sync should automatically apply on pull.
463    #[serde(default)]
464    pub auto_apply: bool,
465
466    /// Whether the sync apply should delete resources that are present on the
467    /// API but missing from the manifest directory. Defaults to `false` —
468    /// the safer behaviour, which skips deletes and only creates/updates.
469    #[serde(default)]
470    pub delete_missing: bool,
471
472    /// How often (in seconds) the continuous reconciliation controller should
473    /// re-apply this sync when `auto_apply` is enabled. `None` falls back to
474    /// the controller's default interval. `Some(0)` is treated the same as
475    /// `None` by the controller (use the default rather than busy-loop).
476    #[serde(default)]
477    pub reconcile_interval_secs: Option<u64>,
478
479    /// The commit SHA at which this sync was last applied.
480    pub last_applied_sha: Option<String>,
481
482    /// When the sync was created.
483    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
484    pub created_at: DateTime<Utc>,
485
486    /// When the sync was last updated.
487    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
488    pub updated_at: DateTime<Utc>,
489}
490
491impl StoredSync {
492    /// Create a new sync record with a fresh UUID.
493    #[must_use]
494    pub fn new(name: impl Into<String>, git_path: impl Into<String>) -> Self {
495        let now = Utc::now();
496        Self {
497            id: Uuid::new_v4().to_string(),
498            name: name.into(),
499            project_id: None,
500            git_path: git_path.into(),
501            auto_apply: false,
502            delete_missing: false,
503            reconcile_interval_secs: None,
504            last_applied_sha: None,
505            created_at: now,
506            updated_at: now,
507        }
508    }
509}
510
511// =========================================================================
512// Tasks
513// =========================================================================
514
515/// A stored task — a named runnable script that can be executed on demand.
516///
517/// Tasks can be global (`project_id = None`) or project-scoped
518/// (`project_id = Some(project_id)`).
519#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
520pub struct StoredTask {
521    /// UUID identifier.
522    pub id: String,
523
524    /// Task name.
525    pub name: String,
526
527    /// Script type.
528    pub kind: TaskKind,
529
530    /// The script/command body.
531    pub body: String,
532
533    /// Project id this task belongs to. `None` = global.
534    pub project_id: Option<String>,
535
536    /// When the task was created.
537    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
538    pub created_at: DateTime<Utc>,
539
540    /// When the task was last updated.
541    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
542    pub updated_at: DateTime<Utc>,
543}
544
545impl StoredTask {
546    /// Create a new task record with a fresh UUID.
547    #[must_use]
548    pub fn new(
549        name: impl Into<String>,
550        kind: TaskKind,
551        body: impl Into<String>,
552        project_id: Option<String>,
553    ) -> Self {
554        let now = Utc::now();
555        Self {
556            id: Uuid::new_v4().to_string(),
557            name: name.into(),
558            kind,
559            body: body.into(),
560            project_id,
561            created_at: now,
562            updated_at: now,
563        }
564    }
565}
566
567/// Script type for a task.
568#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
569#[serde(rename_all = "snake_case")]
570pub enum TaskKind {
571    /// A bash shell script executed via `sh -c`.
572    Bash,
573}
574
575impl std::fmt::Display for TaskKind {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        match self {
578            TaskKind::Bash => f.write_str("bash"),
579        }
580    }
581}
582
583/// A recorded execution of a task.
584#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
585pub struct TaskRun {
586    /// UUID identifier of this run.
587    pub id: String,
588
589    /// The task that was executed.
590    pub task_id: String,
591
592    /// Process exit code (`None` if the task could not be started).
593    pub exit_code: Option<i32>,
594
595    /// Captured standard output.
596    pub stdout: String,
597
598    /// Captured standard error.
599    pub stderr: String,
600
601    /// When the run started.
602    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
603    pub started_at: DateTime<Utc>,
604
605    /// When the run finished (`None` if it has not finished yet).
606    #[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
607    pub finished_at: Option<DateTime<Utc>>,
608}
609
610// =========================================================================
611// Workflows
612// =========================================================================
613
614/// A stored workflow — a named sequence of steps forming a DAG that
615/// composes tasks, project builds, deploys, and sync applies.
616#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
617pub struct StoredWorkflow {
618    /// UUID identifier.
619    pub id: String,
620
621    /// Workflow name.
622    pub name: String,
623
624    /// Ordered list of steps to execute sequentially.
625    pub steps: Vec<WorkflowStep>,
626
627    /// Optional project scope.
628    pub project_id: Option<String>,
629
630    /// When the workflow was created.
631    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
632    pub created_at: DateTime<Utc>,
633
634    /// When the workflow was last updated.
635    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
636    pub updated_at: DateTime<Utc>,
637}
638
639impl StoredWorkflow {
640    /// Create a new workflow record with a fresh UUID.
641    #[must_use]
642    pub fn new(name: impl Into<String>, steps: Vec<WorkflowStep>) -> Self {
643        let now = Utc::now();
644        Self {
645            id: Uuid::new_v4().to_string(),
646            name: name.into(),
647            steps,
648            project_id: None,
649            created_at: now,
650            updated_at: now,
651        }
652    }
653}
654
655/// A single step in a workflow.
656#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
657pub struct WorkflowStep {
658    /// Step name (display label).
659    pub name: String,
660
661    /// The action to perform.
662    pub action: WorkflowAction,
663
664    /// Name of another step (or task id) to run on failure.
665    #[serde(default)]
666    pub on_failure: Option<String>,
667}
668
669/// The action a workflow step performs.
670#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
671#[serde(tag = "type", rename_all = "snake_case")]
672pub enum WorkflowAction {
673    /// Execute a task by id.
674    RunTask {
675        /// The task id to run.
676        task_id: String,
677    },
678    /// Build a project by id.
679    BuildProject {
680        /// The project id to build.
681        project_id: String,
682    },
683    /// Deploy a project by id.
684    DeployProject {
685        /// The project id to deploy.
686        project_id: String,
687    },
688    /// Apply a sync resource by id.
689    ApplySync {
690        /// The sync id to apply.
691        sync_id: String,
692    },
693}
694
695/// A recorded execution of a workflow.
696#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
697pub struct WorkflowRun {
698    /// UUID identifier of this run.
699    pub id: String,
700
701    /// The workflow that was executed.
702    pub workflow_id: String,
703
704    /// Overall run status.
705    pub status: WorkflowRunStatus,
706
707    /// Per-step results.
708    pub step_results: Vec<StepResult>,
709
710    /// When the run started.
711    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
712    pub started_at: DateTime<Utc>,
713
714    /// When the run finished (`None` if still running).
715    #[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
716    pub finished_at: Option<DateTime<Utc>>,
717}
718
719/// Overall status of a workflow run.
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
721#[serde(rename_all = "snake_case")]
722pub enum WorkflowRunStatus {
723    /// Not yet started.
724    Pending,
725    /// Currently executing.
726    Running,
727    /// All steps completed successfully.
728    Completed,
729    /// A step failed.
730    Failed,
731}
732
733impl std::fmt::Display for WorkflowRunStatus {
734    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735        match self {
736            WorkflowRunStatus::Pending => f.write_str("pending"),
737            WorkflowRunStatus::Running => f.write_str("running"),
738            WorkflowRunStatus::Completed => f.write_str("completed"),
739            WorkflowRunStatus::Failed => f.write_str("failed"),
740        }
741    }
742}
743
744/// Result of executing a single step in a workflow run.
745#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
746pub struct StepResult {
747    /// The step name.
748    pub step_name: String,
749
750    /// Step outcome: `"ok"`, `"failed"`, or `"skipped"`.
751    pub status: String,
752
753    /// Optional output or error message.
754    pub output: Option<String>,
755}
756
757// =========================================================================
758// Notifiers
759// =========================================================================
760
761/// A stored notifier — a named notification channel that fires alerts to
762/// Slack, Discord, a generic webhook, or SMTP when triggered.
763#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
764pub struct StoredNotifier {
765    /// UUID identifier.
766    pub id: String,
767
768    /// Display name (e.g. `"deploy-alerts"`).
769    pub name: String,
770
771    /// Notification channel type.
772    pub kind: NotifierKind,
773
774    /// Channel-specific configuration (webhook URL, SMTP settings, etc.).
775    pub config: NotifierConfig,
776
777    /// Whether this notifier is active. Disabled notifiers are skipped.
778    pub enabled: bool,
779
780    /// When the notifier was created.
781    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
782    pub created_at: DateTime<Utc>,
783
784    /// When the notifier was last updated.
785    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
786    pub updated_at: DateTime<Utc>,
787}
788
789impl StoredNotifier {
790    /// Create a new notifier record with a fresh UUID and `enabled = true`.
791    #[must_use]
792    pub fn new(name: impl Into<String>, kind: NotifierKind, config: NotifierConfig) -> Self {
793        let now = Utc::now();
794        Self {
795            id: Uuid::new_v4().to_string(),
796            name: name.into(),
797            kind,
798            config,
799            enabled: true,
800            created_at: now,
801            updated_at: now,
802        }
803    }
804}
805
806/// Notification channel type.
807#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
808#[serde(rename_all = "snake_case")]
809pub enum NotifierKind {
810    /// Slack incoming webhook.
811    Slack,
812    /// Discord webhook.
813    Discord,
814    /// Generic HTTP webhook.
815    Webhook,
816    /// SMTP email.
817    Smtp,
818}
819
820impl std::fmt::Display for NotifierKind {
821    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822        match self {
823            NotifierKind::Slack => f.write_str("slack"),
824            NotifierKind::Discord => f.write_str("discord"),
825            NotifierKind::Webhook => f.write_str("webhook"),
826            NotifierKind::Smtp => f.write_str("smtp"),
827        }
828    }
829}
830
831/// Channel-specific configuration for a notifier.
832#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
833#[serde(tag = "type", rename_all = "snake_case")]
834pub enum NotifierConfig {
835    /// Slack incoming webhook configuration.
836    Slack {
837        /// Slack webhook URL.
838        webhook_url: String,
839    },
840    /// Discord webhook configuration.
841    Discord {
842        /// Discord webhook URL.
843        webhook_url: String,
844    },
845    /// Generic HTTP webhook configuration.
846    Webhook {
847        /// Target URL.
848        url: String,
849        /// HTTP method (defaults to `"POST"`).
850        #[serde(default)]
851        method: Option<String>,
852        /// Extra headers to send with the request.
853        #[serde(default)]
854        headers: Option<HashMap<String, String>>,
855    },
856    /// SMTP email configuration.
857    Smtp {
858        /// SMTP server host.
859        host: String,
860        /// SMTP server port.
861        port: u16,
862        /// SMTP username.
863        username: String,
864        /// SMTP password.
865        password: String,
866        /// Sender email address.
867        from: String,
868        /// Recipient email addresses.
869        to: Vec<String>,
870    },
871}
872
873// =========================================================================
874// User groups
875// =========================================================================
876
877/// A stored user group for role-based access control.
878#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
879pub struct StoredUserGroup {
880    /// UUID identifier.
881    pub id: String,
882
883    /// Group name.
884    pub name: String,
885
886    /// Free-form description.
887    pub description: Option<String>,
888
889    /// When the group was created.
890    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
891    pub created_at: DateTime<Utc>,
892
893    /// When the group was last updated.
894    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
895    pub updated_at: DateTime<Utc>,
896}
897
898impl StoredUserGroup {
899    /// Create a new user group with a fresh UUID.
900    #[must_use]
901    pub fn new(name: impl Into<String>) -> Self {
902        let now = Utc::now();
903        Self {
904            id: Uuid::new_v4().to_string(),
905            name: name.into(),
906            description: None,
907            created_at: now,
908            updated_at: now,
909        }
910    }
911}
912
913// =========================================================================
914// Permissions
915// =========================================================================
916
917/// Whether a permission subject is a user or a group.
918#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
919#[serde(rename_all = "snake_case")]
920pub enum SubjectKind {
921    /// A single user.
922    User,
923    /// A user group.
924    Group,
925}
926
927impl std::fmt::Display for SubjectKind {
928    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
929        match self {
930            SubjectKind::User => f.write_str("user"),
931            SubjectKind::Group => f.write_str("group"),
932        }
933    }
934}
935
936/// Access level for a resource permission, ordered from least to most
937/// privilege.
938#[derive(
939    Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, utoipa::ToSchema,
940)]
941#[serde(rename_all = "snake_case")]
942pub enum PermissionLevel {
943    /// No access.
944    None,
945    /// Read-only access.
946    Read,
947    /// Execute (e.g. deploy, run tasks) in addition to read.
948    Execute,
949    /// Full read/write/execute access.
950    Write,
951}
952
953impl std::fmt::Display for PermissionLevel {
954    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955        match self {
956            PermissionLevel::None => f.write_str("none"),
957            PermissionLevel::Read => f.write_str("read"),
958            PermissionLevel::Execute => f.write_str("execute"),
959            PermissionLevel::Write => f.write_str("write"),
960        }
961    }
962}
963
964/// A stored permission grant binding a subject (user or group) to a resource
965/// with a specific access level.
966///
967/// Canonical `resource_kind` strings (kept here for grep-ability):
968/// - `"environment"` — a `StoredEnvironment` row.
969/// - `"deployment"` — a `StoredDeployment` row.
970/// - `"project"` — a `StoredProject` row.
971/// - `"secret"` — a row in the secrets store (`{scope}:{name}` keyed).
972/// - `"node"` — a cluster member identified by `NodeIdentity::node_id`.
973#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
974pub struct StoredPermission {
975    /// UUID identifier of this permission grant.
976    pub id: String,
977
978    /// Whether the subject is a user or a group.
979    pub subject_kind: SubjectKind,
980
981    /// The user or group id.
982    pub subject_id: String,
983
984    /// The kind of resource (e.g. `"deployment"`, `"project"`, `"secret"`).
985    pub resource_kind: String,
986
987    /// A specific resource id, or `None` for a wildcard (all resources of
988    /// that kind).
989    pub resource_id: Option<String>,
990
991    /// The granted access level.
992    pub level: PermissionLevel,
993
994    /// When the permission was created.
995    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
996    pub created_at: DateTime<Utc>,
997}
998
999impl StoredPermission {
1000    /// Create a new permission grant with a fresh UUID.
1001    #[must_use]
1002    pub fn new(
1003        subject_kind: SubjectKind,
1004        subject_id: impl Into<String>,
1005        resource_kind: impl Into<String>,
1006        resource_id: Option<String>,
1007        level: PermissionLevel,
1008    ) -> Self {
1009        Self {
1010            id: Uuid::new_v4().to_string(),
1011            subject_kind,
1012            subject_id: subject_id.into(),
1013            resource_kind: resource_kind.into(),
1014            resource_id,
1015            level,
1016            created_at: Utc::now(),
1017        }
1018    }
1019}
1020
1021// =========================================================================
1022// Scoped access tokens
1023// =========================================================================
1024
1025/// One scope grant carried inside a scoped access token (and persisted on the
1026/// token record). A scope authorizes `level` access to a resource identified by
1027/// `resource_kind` + `resource_id` (where `resource_id == None` is a wildcard
1028/// covering every resource of that kind).
1029///
1030/// This is the token-embedded analogue of [`StoredPermission`]: a permission
1031/// binds a *subject* to a resource, whereas a `TokenScope` is baked into a
1032/// bearer token so the holder carries its own (attenuated) authority. The
1033/// `resource_kind` strings are the same canonical set documented on
1034/// [`StoredPermission`] (`"deployment"`, `"project"`, `"secret"`, `"node"`,
1035/// `"environment"`).
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
1037pub struct TokenScope {
1038    /// The kind of resource this scope authorizes (e.g. `"deployment"`).
1039    pub resource_kind: String,
1040
1041    /// A specific resource id, or `None` for a wildcard (all resources of that
1042    /// kind).
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub resource_id: Option<String>,
1045
1046    /// The granted access level.
1047    pub level: PermissionLevel,
1048}
1049
1050impl TokenScope {
1051    /// Create a new token scope.
1052    #[must_use]
1053    pub fn new(
1054        resource_kind: impl Into<String>,
1055        resource_id: Option<String>,
1056        level: PermissionLevel,
1057    ) -> Self {
1058        Self {
1059            resource_kind: resource_kind.into(),
1060            resource_id,
1061            level,
1062        }
1063    }
1064
1065    /// Whether this scope satisfies a request for `level` access to
1066    /// `(resource_kind, resource_id)`. The scope matches when the kinds are
1067    /// equal, the scope's `resource_id` is a wildcard (`None`) or exactly equals
1068    /// the requested id, and the scope's level is `>=` the requested level.
1069    #[must_use]
1070    pub fn satisfies(
1071        &self,
1072        resource_kind: &str,
1073        resource_id: Option<&str>,
1074        level: PermissionLevel,
1075    ) -> bool {
1076        self.resource_kind == resource_kind
1077            && self.level >= level
1078            && match (&self.resource_id, resource_id) {
1079                (None, _) => true, // wildcard scope covers any id
1080                (Some(scope_id), Some(req)) => scope_id == req,
1081                (Some(_), None) => false, // a specific-id scope can't authorize a wildcard request
1082            }
1083    }
1084}
1085
1086/// A persisted scoped access token record (the revocation handle + audit row for
1087/// a token minted via the token-management API or for a container).
1088///
1089/// The token itself is a JWT carrying `jti == id` plus the `scopes` below; this
1090/// record lets the daemon revoke it before expiry and list/audit issued tokens.
1091/// The raw JWT is returned to the caller only once at creation and is never
1092/// stored here.
1093#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1094pub struct StoredAccessToken {
1095    /// UUID identifier — also the JWT `jti` claim (the revocation handle).
1096    pub id: String,
1097
1098    /// Human-friendly label for the token (e.g. `"ci-runner"`).
1099    #[serde(default)]
1100    pub name: String,
1101
1102    /// The token's subject (JWT `sub`) — e.g. `token:{id}` for user-minted
1103    /// tokens or `container:{deployment}:{service}:{id}` for container tokens.
1104    pub subject: String,
1105
1106    /// Role claims carried by the token (usually empty for purely-scoped
1107    /// tokens; never `"admin"` unless minted by an admin).
1108    #[serde(default)]
1109    pub roles: Vec<String>,
1110
1111    /// The scopes baked into the token.
1112    #[serde(default)]
1113    pub scopes: Vec<TokenScope>,
1114
1115    /// When the token expires.
1116    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
1117    pub expires_at: DateTime<Utc>,
1118
1119    /// When the token was created.
1120    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
1121    pub created_at: DateTime<Utc>,
1122
1123    /// The subject id (user id or `"system"`) that created the token.
1124    #[serde(default)]
1125    pub created_by: String,
1126
1127    /// When the token was revoked, if it has been. `None` = still active.
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    #[schema(value_type = Option<String>, example = "2026-04-15T12:00:00Z")]
1130    pub revoked_at: Option<DateTime<Utc>>,
1131}
1132
1133impl StoredAccessToken {
1134    /// Create a new active token record with a fresh UUID id.
1135    #[must_use]
1136    pub fn new(
1137        name: impl Into<String>,
1138        subject: impl Into<String>,
1139        roles: Vec<String>,
1140        scopes: Vec<TokenScope>,
1141        expires_at: DateTime<Utc>,
1142        created_by: impl Into<String>,
1143    ) -> Self {
1144        Self {
1145            id: Uuid::new_v4().to_string(),
1146            name: name.into(),
1147            subject: subject.into(),
1148            roles,
1149            scopes,
1150            expires_at,
1151            created_at: Utc::now(),
1152            created_by: created_by.into(),
1153            revoked_at: None,
1154        }
1155    }
1156
1157    /// Whether the token is revoked or has expired as of `now`.
1158    #[must_use]
1159    pub fn is_inactive(&self, now: DateTime<Utc>) -> bool {
1160        self.revoked_at.is_some() || self.expires_at < now
1161    }
1162}
1163
1164// =========================================================================
1165// OIDC identities
1166// =========================================================================
1167
1168/// One OIDC identity link row.
1169///
1170/// One row is inserted the first time a user signs in via a given provider;
1171/// subsequent sign-ins look up the same row and reuse the linked `user_id`.
1172/// The uniqueness constraint on `(provider, subject)` enforces the
1173/// one-subject-one-user invariant.
1174#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1175pub struct OidcIdentity {
1176    /// Surrogate id (uuid).
1177    pub id: String,
1178    /// `ZLayer` user account this identity resolves to.
1179    pub user_id: String,
1180    /// Provider slug matching `OidcProviderConfig::name`.
1181    pub provider: String,
1182    /// The `sub` claim from the provider's ID token. Opaque.
1183    pub subject: String,
1184    /// Email returned by the provider at link time (informational only).
1185    pub email_at_link: Option<String>,
1186    #[schema(value_type = String, format = DateTime)]
1187    pub created_at: DateTime<Utc>,
1188    #[schema(value_type = String, format = DateTime)]
1189    pub updated_at: DateTime<Utc>,
1190}
1191
1192impl OidcIdentity {
1193    /// Convenience constructor — fills `id`, `created_at`, `updated_at`.
1194    #[must_use]
1195    pub fn new(
1196        user_id: impl Into<String>,
1197        provider: impl Into<String>,
1198        subject: impl Into<String>,
1199        email_at_link: Option<String>,
1200    ) -> Self {
1201        let now = Utc::now();
1202        Self {
1203            id: Uuid::new_v4().to_string(),
1204            user_id: user_id.into(),
1205            provider: provider.into(),
1206            subject: subject.into(),
1207            email_at_link,
1208            created_at: now,
1209            updated_at: now,
1210        }
1211    }
1212}
1213
1214// =========================================================================
1215// Audit log
1216//
1217// `AuditEntry` is intentionally NOT moved here: it carries an
1218// `Option<serde_json::Value>` field, and `zlayer-types` does not depend on
1219// `serde_json`. Once a sibling migration adds the dep (or restructures the
1220// `details` field to a string), `AuditEntry` can move alongside the other
1221// `Stored*` types — it lives in `zlayer-api::storage` for now.
1222// =========================================================================
1223
1224// =========================================================================
1225// Cluster-replicated secrets (Phase 1 — Raft + sealed-box DEK wrap)
1226// =========================================================================
1227
1228/// Per-node identity and key material.
1229///
1230/// Each node generates an X25519 keypair on first start and publishes the
1231/// pubkey via `RegisterNode` during cluster join. Lives in Raft state so
1232/// every node knows the recipient set when wrapping the cluster DEK.
1233#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1234pub struct NodeIdentity {
1235    /// Cluster-wide UUID for this node (matches Raft's view of the node).
1236    pub node_id: String,
1237
1238    /// 32-byte X25519 pubkey used as the sealed-box recipient when the
1239    /// leader wraps the cluster DEK to this node.
1240    #[schema(value_type = String, format = "byte")]
1241    pub secrets_pubkey: [u8; 32],
1242
1243    /// `WireGuard` pubkey, kept here for reference — overlay/tunnelling
1244    /// already track this elsewhere; duplicating it in `NodeIdentity` lets
1245    /// callers correlate sealed-box identity with overlay identity without
1246    /// a second lookup.
1247    pub wg_pubkey: String,
1248
1249    /// When this node was registered with the cluster.
1250    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
1251    pub joined_at: DateTime<Utc>,
1252
1253    /// Soft-revocation timestamp. When set, the node no longer receives
1254    /// new wraps and must be excluded from `RotateDek` after a quorum
1255    /// confirms it has been physically removed.
1256    #[schema(value_type = Option<String>, example = "2026-04-16T12:00:00Z")]
1257    pub revoked_at: Option<DateTime<Utc>>,
1258}
1259
1260/// The cluster data-encryption key (DEK), wrapped per-node so each member
1261/// can decrypt without ever holding a shared cluster-wide private key.
1262///
1263/// The DEK itself is never stored anywhere; only the per-node sealed-box
1264/// wraps live in Raft. A node decrypts its own wrap on startup using its
1265/// node X25519 private key, and holds the unwrapped DEK in zeroized memory.
1266///
1267/// Generation increments on every rotation (e.g. node revocation, scheduled
1268/// rotation, suspected compromise). Every `ReplicatedSecret` records the
1269/// `dek_generation` it was encrypted under so re-encrypts can be batched.
1270#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1271pub struct WrappedDek {
1272    /// Monotonically increasing generation counter.
1273    pub dek_generation: u64,
1274
1275    /// Map from `node_id` to that node's sealed-box-wrapped copy of the DEK.
1276    /// A node missing from this map cannot decrypt any secret encrypted
1277    /// under this generation and must be re-wrapped via `RegisterNode` (or
1278    /// through a `RotateDek` that includes it).
1279    pub wraps: std::collections::HashMap<String, Vec<u8>>,
1280}
1281
1282/// A secret replicated through Raft. Every node has the same encrypted
1283/// blob; only nodes whose `secrets_pubkey` is in the current `WrappedDek`
1284/// for this generation can decrypt.
1285#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1286pub struct ReplicatedSecret {
1287    /// `"{scope}:{name}"` — same key shape used by `PersistentSecretsStore`.
1288    pub storage_key: String,
1289
1290    /// XChaCha20-Poly1305 ciphertext of the plaintext value, encrypted
1291    /// under the cluster DEK at `dek_generation`. Nonce is prepended.
1292    pub ciphertext: Vec<u8>,
1293
1294    /// Which DEK generation produced `ciphertext`. After a rotation, the
1295    /// state machine batches re-encrypts of every row whose `dek_generation`
1296    /// is older than current.
1297    pub dek_generation: u64,
1298
1299    /// Standard secret metadata (name, version, timestamps).
1300    #[schema(value_type = Object)]
1301    pub metadata: crate::secrets::SecretMetadata,
1302
1303    /// Optional per-secret affinity. `None` = any node may host (the
1304    /// default). When set, only matching nodes are entitled to a wrap of
1305    /// this row's DEK material; the API gate also filters reads accordingly.
1306    #[serde(default, skip_serializing_if = "Option::is_none")]
1307    pub node_affinity: Option<NodeAffinity>,
1308}
1309
1310/// Constrains which nodes are allowed to host a given secret's
1311/// decryptable form. Used as the value of `ReplicatedSecret.node_affinity`.
1312/// `None` on a secret = unconstrained (any node may host); `Some(...)`
1313/// = only matching nodes receive a wrap of this row's DEK material,
1314/// and the API gate filters reads accordingly.
1315#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1316#[serde(tag = "kind", rename_all = "snake_case")]
1317pub enum NodeAffinity {
1318    /// Explicit allow-list of `node_id`s.
1319    Nodes {
1320        /// The set of `node_id`s entitled to host this secret.
1321        node_ids: Vec<String>,
1322    },
1323
1324    /// Match by node labels — every entry in the map must be present on
1325    /// the node. Empty map matches every node.
1326    Labels {
1327        /// Required label key/value pairs.
1328        labels: std::collections::HashMap<String, String>,
1329    },
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    /// A `StoredDeployment` JSON payload written before
1337    /// `resolved_image_digests` existed (no such key) must still deserialize,
1338    /// and the field must default to an empty map. This guards the
1339    /// "poisoned deployments.db" regression class: a schema addition that
1340    /// silently fails to load older persisted rows.
1341    #[test]
1342    fn stored_deployment_deserializes_without_resolved_image_digests() {
1343        // No `resolved_image_digests` key — exactly what older rows look like.
1344        let json = r#"{
1345            "name": "legacy",
1346            "spec": {
1347                "version": "v1",
1348                "deployment": "legacy",
1349                "services": {},
1350                "externals": {},
1351                "tunnels": {},
1352                "api": {}
1353            },
1354            "status": { "state": "running" },
1355            "created_at": "2025-01-27T12:00:00Z",
1356            "updated_at": "2025-01-27T12:00:00Z"
1357        }"#;
1358
1359        let stored: StoredDeployment =
1360            serde_json::from_str(json).expect("legacy row without the field must deserialize");
1361
1362        assert_eq!(stored.name, "legacy");
1363        assert_eq!(stored.status, DeploymentStatus::Running);
1364        assert!(
1365            stored.resolved_image_digests.is_empty(),
1366            "missing field must default to an empty map"
1367        );
1368    }
1369
1370    /// Round-trip a `StoredDeployment` that DOES carry per-service resolved
1371    /// digests, proving forward-compatibility (the entries survive serialize →
1372    /// deserialize keyed by service name).
1373    #[test]
1374    fn stored_deployment_round_trips_resolved_image_digests() {
1375        let spec = crate::spec::DeploymentSpec {
1376            version: "v1".to_string(),
1377            deployment: "multi".to_string(),
1378            services: HashMap::new(),
1379            externals: HashMap::new(),
1380            tunnels: HashMap::new(),
1381            api: crate::spec::ApiSpec::default(),
1382            environment: None,
1383            project: None,
1384        };
1385        let mut stored = StoredDeployment::new(spec);
1386        stored
1387            .resolved_image_digests
1388            .insert("web".to_string(), "sha256:aaa".to_string());
1389        stored
1390            .resolved_image_digests
1391            .insert("worker".to_string(), "sha256:bbb".to_string());
1392
1393        let json = serde_json::to_string(&stored).expect("serialize");
1394        let back: StoredDeployment = serde_json::from_str(&json).expect("deserialize");
1395
1396        assert_eq!(back.resolved_image_digests.len(), 2);
1397        assert_eq!(
1398            back.resolved_image_digests.get("web").map(String::as_str),
1399            Some("sha256:aaa")
1400        );
1401        assert_eq!(
1402            back.resolved_image_digests
1403                .get("worker")
1404                .map(String::as_str),
1405            Some("sha256:bbb")
1406        );
1407    }
1408
1409    /// `skip_serializing_if` must omit the field entirely when the map is
1410    /// empty, keeping rows that never resolved a digest from being bloated.
1411    #[test]
1412    fn stored_deployment_omits_empty_resolved_image_digests_on_serialize() {
1413        let spec = crate::spec::DeploymentSpec {
1414            version: "v1".to_string(),
1415            deployment: "empty".to_string(),
1416            services: HashMap::new(),
1417            externals: HashMap::new(),
1418            tunnels: HashMap::new(),
1419            api: crate::spec::ApiSpec::default(),
1420            environment: None,
1421            project: None,
1422        };
1423        let stored = StoredDeployment::new(spec);
1424
1425        let json = serde_json::to_string(&stored).expect("serialize");
1426        assert!(
1427            !json.contains("resolved_image_digests"),
1428            "empty map must be skipped on serialize, got: {json}"
1429        );
1430    }
1431}