Skip to main content

alien_core/
stack.rs

1use crate::permissions::{ManagementPermissions, PermissionProfile, PermissionsConfig};
2use crate::{Platform, Resource, ResourceLifecycle, ResourceRef, StackInputDefinition};
3use bon::Builder;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10#[serde(rename_all = "camelCase")]
11pub struct ResourceEntry {
12    /// Resource configuration (can be any type of resource)
13    pub config: Resource,
14    /// Lifecycle management configuration for this resource
15    pub lifecycle: ResourceLifecycle,
16    /// Additional dependencies for this resource beyond those defined in the resource itself.
17    /// The total dependencies are: resource.get_dependencies() + this list
18    pub dependencies: Vec<ResourceRef>,
19    /// Enable remote bindings for this resource (BYOB use case).
20    /// When true, binding params are synced to StackState's `remote_binding_params`.
21    /// Default: false (prevents sensitive data in synced state).
22    #[serde(default)]
23    pub remote_access: bool,
24    /// Id of the boolean stack input that decides whether this resource is
25    /// created at all. `None` means always create it.
26    ///
27    /// Set by `.enabled(input)` in the SDK. Setup emitters render the resource
28    /// conditionally on the matching template variable, so a deployer who says no
29    /// never gets the resource, its outputs, or anything derived from it.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub enabled_when: Option<String>,
32}
33
34impl ResourceEntry {
35    /// Returns whether this resource is published through Remote Bindings.
36    ///
37    /// Provider emitters use this generic signal to create the stack-level
38    /// Remote Bindings identity. Each resource emitter remains responsible for
39    /// granting that identity only the resource's declared data-plane access.
40    pub fn has_remote_bindings(&self) -> bool {
41        self.remote_access
42    }
43
44    /// Returns whether this entry is Frozen Storage published for remote access.
45    pub fn is_remote_frozen_storage(&self) -> bool {
46        self.lifecycle == ResourceLifecycle::Frozen
47            && self.remote_access
48            && self.config.downcast_ref::<crate::Storage>().is_some()
49    }
50}
51
52/// A bag of resources, unaware of any cloud.
53#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[serde(rename_all = "camelCase")]
56#[builder(start_fn = new)]
57pub struct Stack {
58    /// Unique identifier for the stack
59    #[builder(start_fn)]
60    pub id: String,
61    /// Map of resource IDs to their configurations and lifecycle settings
62    #[builder(field)]
63    pub resources: IndexMap<String, ResourceEntry>,
64    /// Combined permissions configuration containing both profiles and management
65    #[builder(field)]
66    #[serde(default)]
67    pub permissions: PermissionsConfig,
68    /// Which platforms this stack supports. When None, all platforms are supported.
69    #[builder(field)]
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub supported_platforms: Option<Vec<Platform>>,
72    /// Input definitions required before setup or deployment can proceed.
73    #[builder(field)]
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub inputs: Vec<StackInputDefinition>,
76}
77
78impl Stack {
79    /// Returns a deterministic digest of the complete Frozen resource set.
80    /// Resource and object-key ordering do not affect the digest.
81    pub fn frozen_resources_digest(&self) -> String {
82        let mut resources = self
83            .resources
84            .iter()
85            .filter(|(_, entry)| entry.lifecycle == ResourceLifecycle::Frozen)
86            .map(|(id, entry)| {
87                let mut value =
88                    serde_json::to_value(entry).expect("resource entries always serialize to JSON");
89                canonicalize_json(&mut value);
90                (id, value)
91            })
92            .collect::<Vec<_>>();
93        resources.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
94
95        let encoded = serde_json::to_vec(&resources)
96            .expect("canonical Frozen resource projection always serializes");
97        format!("{:x}", Sha256::digest(encoded))
98    }
99    /// Returns an iterator over the resources in the stack, including their lifecycle state.
100    pub fn resources(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
101        self.resources.iter()
102    }
103
104    /// Returns a mutable iterator over the resources in the stack, including their lifecycle state.
105    pub fn resources_mut(&mut self) -> impl Iterator<Item = (&String, &mut ResourceEntry)> {
106        self.resources.iter_mut()
107    }
108
109    pub fn id(&self) -> &str {
110        &self.id
111    }
112
113    /// Create a reference to the current stack
114    pub fn current() -> StackRef {
115        StackRef::Current
116    }
117
118    /// Returns the permissions configuration for the stack.
119    pub fn permissions(&self) -> &PermissionsConfig {
120        &self.permissions
121    }
122
123    /// Returns the permission profiles for the stack.
124    pub fn permission_profiles(&self) -> &IndexMap<String, PermissionProfile> {
125        &self.permissions.profiles
126    }
127
128    /// Returns the management permissions configuration for the stack.
129    pub fn management(&self) -> &ManagementPermissions {
130        &self.permissions.management
131    }
132
133    /// Returns the supported platforms, or None if all platforms are supported.
134    pub fn supported_platforms(&self) -> Option<&[Platform]> {
135        self.supported_platforms.as_deref()
136    }
137
138    /// Returns stack input definitions.
139    pub fn inputs(&self) -> &[StackInputDefinition] {
140        &self.inputs
141    }
142
143    /// Returns true if the given platform is supported by this stack.
144    /// When supported_platforms is None, all platforms are supported.
145    pub fn supports_platform(&self, platform: &Platform) -> bool {
146        match &self.supported_platforms {
147            Some(platforms) => platforms.contains(platform),
148            None => true,
149        }
150    }
151}
152
153fn canonicalize_json(value: &mut serde_json::Value) {
154    match value {
155        serde_json::Value::Array(values) => {
156            for value in values {
157                canonicalize_json(value);
158            }
159        }
160        serde_json::Value::Object(object) => {
161            let mut entries = std::mem::take(object).into_iter().collect::<Vec<_>>();
162            entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
163            for (key, mut value) in entries {
164                canonicalize_json(&mut value);
165                object.insert(key, value);
166            }
167        }
168        _ => {}
169    }
170}
171
172impl StackBuilder {
173    /// Adds a resource to the stack with its lifecycle state.
174    /// The resource's intrinsic dependencies (from resource.get_dependencies()) are automatically included.
175    /// Use add_with_dependencies() if you need to specify additional dependencies.
176    pub fn add<T: crate::ResourceDefinition>(
177        self,
178        resource: T,
179        lifecycle: ResourceLifecycle,
180    ) -> Self {
181        self.add_with_dependencies(resource, lifecycle, vec![])
182    }
183
184    /// Adds a resource to the stack with its lifecycle state and additional dependencies.
185    /// The total dependencies will be: resource.get_dependencies() + additional_dependencies
186    pub fn add_with_dependencies<T: crate::ResourceDefinition>(
187        self,
188        resource: T,
189        lifecycle: ResourceLifecycle,
190        additional_dependencies: Vec<ResourceRef>,
191    ) -> Self {
192        let mut entry = Self::entry(resource, lifecycle);
193        entry.dependencies = additional_dependencies;
194        self.insert(entry)
195    }
196
197    /// Adds a resource whose creation follows a boolean stack input.
198    /// The deployer's answer decides whether it is provisioned at all.
199    ///
200    /// Stacks are authored through the TypeScript SDK's `.enabled(input)`, which
201    /// sets the field on the resource; this is the Rust-side seam the generator
202    /// and preflight tests build gated stacks with.
203    #[doc(hidden)]
204    pub fn add_enabled_when<T: crate::ResourceDefinition>(
205        self,
206        resource: T,
207        lifecycle: ResourceLifecycle,
208        input_id: impl Into<String>,
209    ) -> Self {
210        let mut entry = Self::entry(resource, lifecycle);
211        entry.enabled_when = Some(input_id.into());
212        self.insert(entry)
213    }
214
215    /// Adds a resource with remote access enabled.
216    /// When remote_access is true, binding params are synced to StackState for external access.
217    pub fn add_with_remote_access<T: crate::ResourceDefinition>(
218        self,
219        resource: T,
220        lifecycle: ResourceLifecycle,
221    ) -> Self {
222        let mut entry = Self::entry(resource, lifecycle);
223        entry.remote_access = true;
224        self.insert(entry)
225    }
226
227    /// The only place a `ResourceEntry` is spelled out. Each public `add_*`
228    /// varies one field of it, so a new per-entry field costs one edit here
229    /// instead of one per method.
230    fn entry<T: crate::ResourceDefinition>(
231        resource: T,
232        lifecycle: ResourceLifecycle,
233    ) -> ResourceEntry {
234        ResourceEntry {
235            config: Resource::new(resource),
236            lifecycle,
237            dependencies: Vec::new(),
238            remote_access: false,
239            enabled_when: None,
240        }
241    }
242
243    fn insert(mut self, entry: ResourceEntry) -> Self {
244        self.resources.insert(entry.config.id().to_string(), entry);
245        self
246    }
247
248    /// Sets the permissions configuration for the stack.
249    /// This defines access control for compute services in the stack.
250    pub fn permissions(mut self, permissions: PermissionsConfig) -> Self {
251        self.permissions = permissions;
252        self
253    }
254
255    /// Add a single permission profile to the stack - allows fluent chaining
256    ///
257    /// # Example
258    /// ```rust
259    /// # use alien_core::{Stack, permissions::PermissionProfile};
260    /// Stack::new("my-stack".to_string())
261    ///     .permission("execution", PermissionProfile::new().global(["storage/data-read"]))
262    ///     .permission("management", PermissionProfile::new().global(["storage/management"]))
263    ///     .build()
264    /// # ;
265    /// ```
266    pub fn permission(mut self, name: impl Into<String>, profile: PermissionProfile) -> Self {
267        self.permissions.profiles.insert(name.into(), profile);
268        self
269    }
270
271    /// Sets the supported platforms for this stack.
272    pub fn platforms(mut self, platforms: Vec<Platform>) -> Self {
273        self.supported_platforms = Some(platforms);
274        self
275    }
276
277    /// Sets stack input definitions.
278    pub fn inputs(mut self, inputs: Vec<StackInputDefinition>) -> Self {
279        self.inputs = inputs;
280        self
281    }
282
283    /// Sets the management permissions configuration for the stack.
284    /// This defines how management permissions are derived and configured.
285    ///
286    /// # Examples
287    /// ```rust
288    /// # use alien_core::{Stack, permissions::{ManagementPermissions, PermissionProfile}};
289    /// // Auto-derived management permissions (default)
290    /// Stack::new("my-stack".to_string())
291    ///     .management(ManagementPermissions::auto())
292    ///     .build();
293    ///
294    /// // Extend auto-derived permissions
295    /// Stack::new("my-stack".to_string())
296    ///     .management(ManagementPermissions::extend(
297    ///         PermissionProfile::new().global(["vault/data-write"])
298    ///     ))
299    ///     .build();
300    ///
301    /// // Override auto-derived permissions entirely
302    /// Stack::new("my-stack".to_string())
303    ///     .management(ManagementPermissions::override_(
304    ///         PermissionProfile::new().global(["storage/heartbeat", "worker/provision"])
305    ///     ))
306    ///     .build();
307    /// ```
308    pub fn management(mut self, management: ManagementPermissions) -> Self {
309        self.permissions.management = management;
310        self
311    }
312}
313
314/// Reference to a stack for management permissions
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
317#[serde(rename_all = "camelCase")]
318pub enum StackRef {
319    /// Reference to the current stack being built
320    Current,
321    /// Reference to another stack by ID
322    External(String),
323}
324
325impl StackRef {
326    /// Create a StackRef from a stack reference
327    pub fn from_stack(stack: &Stack) -> Self {
328        StackRef::External(stack.id().to_string())
329    }
330}
331
332impl From<&Stack> for StackRef {
333    fn from(stack: &Stack) -> Self {
334        StackRef::External(stack.id().to_string())
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::resource::ResourceLifecycle;
342    use crate::{
343        Container, ContainerCode, Daemon, DaemonCode, PermissionSetReference, ResourceSpec,
344        Storage, Worker, WorkerCode,
345    };
346    use insta::assert_json_snapshot;
347
348    fn resource_entry<T: crate::ResourceDefinition>(
349        resource: T,
350        lifecycle: ResourceLifecycle,
351        remote_access: bool,
352    ) -> ResourceEntry {
353        ResourceEntry {
354            config: Resource::new(resource),
355            lifecycle,
356            dependencies: Vec::new(),
357            remote_access,
358            enabled_when: None,
359        }
360    }
361
362    #[test]
363    fn remote_frozen_storage_requires_storage_lifecycle_and_opt_in() {
364        assert!(resource_entry(
365            Storage::new("archive".to_string()).build(),
366            ResourceLifecycle::Frozen,
367            true,
368        )
369        .is_remote_frozen_storage());
370        assert!(!resource_entry(
371            Storage::new("archive".to_string()).build(),
372            ResourceLifecycle::Frozen,
373            false,
374        )
375        .is_remote_frozen_storage());
376        assert!(!resource_entry(
377            Storage::new("archive".to_string()).build(),
378            ResourceLifecycle::Live,
379            true,
380        )
381        .is_remote_frozen_storage());
382        assert!(!resource_entry(
383            Worker::new("worker".to_string())
384                .code(WorkerCode::Image {
385                    image: "example.com/worker:latest".to_string(),
386                })
387                .permissions("worker-execution".to_string())
388                .build(),
389            ResourceLifecycle::Frozen,
390            true,
391        )
392        .is_remote_frozen_storage());
393    }
394
395    #[test]
396    fn remote_bindings_opt_in_is_resource_agnostic() {
397        let worker = resource_entry(
398            Worker::new("worker".to_string())
399                .code(WorkerCode::Image {
400                    image: "example.com/worker:latest".to_string(),
401                })
402                .permissions("worker-execution".to_string())
403                .build(),
404            ResourceLifecycle::Live,
405            true,
406        );
407
408        assert!(worker.has_remote_bindings());
409        assert!(!worker.is_remote_frozen_storage());
410    }
411
412    #[test]
413    fn test_stack_serialization() {
414        use crate::WorkerCode;
415
416        let storage = Storage::new("my-bucket".to_string())
417            .public_read(true)
418            .build();
419
420        let worker = Worker::new("my-worker".to_string())
421            .code(WorkerCode::Image {
422                image: "rust:latest".to_string(),
423            })
424            .permissions("execution".to_string())
425            .link(&storage)
426            .build();
427
428        // Create permission profiles for the new system
429        let mut permissions = IndexMap::new();
430        let mut execution_profile = PermissionProfile::new();
431        execution_profile.0.insert(
432            "*".to_string(),
433            vec![
434                PermissionSetReference::from_name("storage/data-read"),
435                PermissionSetReference::from_name("storage/data-write"),
436            ],
437        );
438        permissions.insert("execution".to_string(), execution_profile);
439
440        let stack_builder = Stack::new("test-stack".to_string())
441            .add(storage, ResourceLifecycle::Frozen)
442            .add(worker.clone(), ResourceLifecycle::Live);
443
444        let stack = stack_builder
445            .permissions(PermissionsConfig {
446                profiles: permissions,
447                management: ManagementPermissions::Auto,
448            })
449            .build();
450
451        // Serialize and Deserialize
452        let serialized_stack =
453            serde_json::to_string_pretty(&stack).expect("Failed to serialize stack");
454        let deserialized_stack: Stack =
455            serde_json::from_str(&serialized_stack).expect("Failed to deserialize stack");
456
457        // Assert equality
458        assert_eq!(
459            stack, deserialized_stack,
460            "Original and deserialized stacks do not match."
461        );
462
463        // Verify snapshot (sort maps to be deterministic across Rust versions)
464        let mut settings = insta::Settings::clone_current();
465        settings.set_sort_maps(true);
466        settings.bind(|| {
467            assert_json_snapshot!("stack_serialization_account_managed", stack);
468        });
469    }
470
471    #[test]
472    fn test_empty_stack_serialization() {
473        let stack_builder = Stack::new("empty-test-stack".to_string());
474
475        let stack = stack_builder
476            .permissions(PermissionsConfig::new()) // Empty permissions for existing tests
477            .build();
478
479        // Serialize and Deserialize
480        let serialized_stack =
481            serde_json::to_string_pretty(&stack).expect("Failed to serialize empty stack");
482        let deserialized_stack: Stack =
483            serde_json::from_str(&serialized_stack).expect("Failed to deserialize empty stack");
484
485        // Assert equality
486        assert_eq!(
487            stack, deserialized_stack,
488            "Original and deserialized empty stacks do not match."
489        );
490
491        // Verify snapshot (sort maps to be deterministic across Rust versions)
492        let mut settings = insta::Settings::clone_current();
493        settings.set_sort_maps(true);
494        settings.bind(|| {
495            assert_json_snapshot!("empty_stack_serialization_account", stack);
496        });
497    }
498
499    #[test]
500    fn stack_deserializes_resources_without_public_endpoints() {
501        let container = Container::new("api".to_string())
502            .code(ContainerCode::Image {
503                image: "example.com/api:latest".to_string(),
504            })
505            .cpu(ResourceSpec {
506                min: "0.5".to_string(),
507                desired: "1".to_string(),
508            })
509            .memory(ResourceSpec {
510                min: "512Mi".to_string(),
511                desired: "1Gi".to_string(),
512            })
513            .port(8080)
514            .permissions("container-execution".to_string())
515            .build();
516        let daemon = Daemon::new("agent".to_string())
517            .code(DaemonCode::Image {
518                image: "example.com/agent:latest".to_string(),
519            })
520            .permissions("daemon-execution".to_string())
521            .build();
522        let worker = Worker::new("worker".to_string())
523            .code(WorkerCode::Image {
524                image: "example.com/worker:latest".to_string(),
525            })
526            .permissions("worker-execution".to_string())
527            .build();
528        let stack = Stack::new("legacy-stack".to_string())
529            .add(container, ResourceLifecycle::Live)
530            .add(daemon, ResourceLifecycle::Live)
531            .add(worker, ResourceLifecycle::Live)
532            .build();
533
534        let mut legacy_json = serde_json::to_value(stack).expect("stack should serialize");
535        for resource_id in ["api", "agent", "worker"] {
536            legacy_json
537                .pointer_mut(&format!("/resources/{resource_id}/config"))
538                .and_then(serde_json::Value::as_object_mut)
539                .expect("resource config should be an object")
540                .remove("publicEndpoints");
541        }
542
543        let stack: Stack =
544            serde_json::from_value(legacy_json).expect("legacy stack should deserialize");
545
546        let container = stack
547            .resources
548            .get("api")
549            .and_then(|entry| entry.config.downcast_ref::<Container>())
550            .expect("api should be a container");
551        assert!(container.public_endpoints.is_empty());
552
553        let daemon = stack
554            .resources
555            .get("agent")
556            .and_then(|entry| entry.config.downcast_ref::<Daemon>())
557            .expect("agent should be a daemon");
558        assert!(daemon.public_endpoints.is_empty());
559
560        let worker = stack
561            .resources
562            .get("worker")
563            .and_then(|entry| entry.config.downcast_ref::<Worker>())
564            .expect("worker should be a worker");
565        assert!(worker.public_endpoints.is_empty());
566    }
567
568    #[test]
569    fn test_stack_with_permissions() {
570        use crate::permissions::PermissionProfile;
571        use indexmap::IndexMap;
572
573        // Create a simple stack with permissions
574        let storage = Storage::new("test-storage".to_string()).build();
575
576        // Create a permission profile
577        let mut permission_profile = PermissionProfile::new();
578        permission_profile.0.insert(
579            "*".to_string(),
580            vec![PermissionSetReference::from_name("storage/data-read")],
581        );
582
583        let mut permissions = IndexMap::new();
584        permissions.insert("reader".to_string(), permission_profile);
585
586        let stack = Stack::new("test-permissions-stack".to_string())
587            .add(storage, ResourceLifecycle::Frozen)
588            .permissions(PermissionsConfig {
589                profiles: permissions,
590                management: ManagementPermissions::Auto,
591            })
592            .build();
593
594        // Verify permissions are accessible
595        assert_eq!(stack.permission_profiles().len(), 1);
596        assert!(stack.permission_profiles().contains_key("reader"));
597
598        let reader_profile = stack.permission_profiles().get("reader").unwrap();
599        assert_eq!(reader_profile.0.len(), 1);
600        assert!(reader_profile.0.contains_key("*"));
601
602        let global_permissions = reader_profile.0.get("*").unwrap();
603        assert_eq!(
604            global_permissions,
605            &vec![PermissionSetReference::from_name("storage/data-read")]
606        );
607
608        // Test serialization/deserialization
609        let serialized = serde_json::to_string_pretty(&stack).expect("Failed to serialize");
610        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
611        assert_eq!(stack, deserialized);
612    }
613
614    #[test]
615    fn test_stack_with_management_permissions() {
616        use crate::permissions::{ManagementPermissions, PermissionProfile};
617
618        // Create a simple stack with management permissions
619        let storage = Storage::new("test-storage".to_string()).build();
620
621        // Create a permission profile for management
622        let mut management_profile = PermissionProfile::new();
623        management_profile.0.insert(
624            "*".to_string(),
625            vec![PermissionSetReference::from_name("vault/data-write")],
626        );
627
628        // Test auto management permissions (default)
629        let stack_auto = Stack::new("test-auto-management-stack".to_string())
630            .add(storage.clone(), ResourceLifecycle::Frozen)
631            .management(ManagementPermissions::auto())
632            .build();
633
634        assert!(stack_auto.management().is_auto());
635        assert!(stack_auto.management().profile().is_none());
636
637        // Test extend management permissions
638        let stack_extend = Stack::new("test-extend-management-stack".to_string())
639            .add(storage.clone(), ResourceLifecycle::Frozen)
640            .management(ManagementPermissions::extend(management_profile.clone()))
641            .build();
642
643        assert!(stack_extend.management().is_extend());
644        assert_eq!(
645            stack_extend.management().profile().unwrap(),
646            &management_profile
647        );
648
649        // Test override management permissions
650        let stack_override = Stack::new("test-override-management-stack".to_string())
651            .add(storage.clone(), ResourceLifecycle::Frozen)
652            .management(ManagementPermissions::override_(management_profile.clone()))
653            .build();
654
655        assert!(stack_override.management().is_override());
656        assert_eq!(
657            stack_override.management().profile().unwrap(),
658            &management_profile
659        );
660
661        // Test default management permissions
662        let stack_default = Stack::new("test-default-management-stack".to_string())
663            .add(storage, ResourceLifecycle::Frozen)
664            .build();
665
666        assert!(stack_default.management().is_auto());
667
668        // Test serialization/deserialization with management
669        let serialized = serde_json::to_string_pretty(&stack_extend).expect("Failed to serialize");
670        let deserialized: Stack = serde_json::from_str(&serialized).expect("Failed to deserialize");
671        assert_eq!(stack_extend, deserialized);
672    }
673
674    #[test]
675    fn frozen_resource_digest_is_order_independent_and_ignores_live_resources() {
676        let first = Stack::new("first".to_string())
677            .add(
678                Storage::new("alpha".to_string()).build(),
679                ResourceLifecycle::Frozen,
680            )
681            .add(
682                Storage::new("beta".to_string()).build(),
683                ResourceLifecycle::Frozen,
684            )
685            .add(
686                Storage::new("live-one".to_string()).build(),
687                ResourceLifecycle::Live,
688            )
689            .build();
690        let second = Stack::new("second".to_string())
691            .add(
692                Storage::new("beta".to_string()).build(),
693                ResourceLifecycle::Frozen,
694            )
695            .add(
696                Storage::new("alpha".to_string()).build(),
697                ResourceLifecycle::Frozen,
698            )
699            .add(
700                Storage::new("live-two".to_string()).build(),
701                ResourceLifecycle::Live,
702            )
703            .build();
704
705        assert_eq!(
706            first.frozen_resources_digest(),
707            second.frozen_resources_digest()
708        );
709    }
710}