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}