Skip to main content

vela_constellation/
lib.rs

1//! # vela-constellation
2//!
3//! The Vela Constellation layer: a network of connected Atlases
4//! across scientific domains.
5//!
6//! ## What a Constellation is
7//!
8//! A Constellation (`vco_*`) is a **read-only composition** over
9//! one or more Atlases (`vat_*`), each itself a composition over
10//! Vela frontiers (`vfr_*`). The substrate stack:
11//!
12//! ```text
13//! Frontier (vfr_*)        bounded reviewable state, unit of replay
14//!     │
15//!     │ composed into
16//!     ▼
17//! Atlas (vat_*)           living domain map, unit of reviewer-
18//!                         confirmed bridges
19//!     │
20//!     │ networked into
21//!     ▼
22//! Constellation (vco_*)   cross-domain map, read-only over Atlas
23//!                         snapshots
24//! ```
25//!
26//! See `docs/MISSION_ATLAS.md`. The Carina v0.5 schema for the
27//! Constellation primitive ships at
28//! `examples/carina-kernel/schemas/constellation.schema.json`.
29//!
30//! ## What this crate ships at v0.81
31//!
32//! - `ConstellationManifest`: typed
33//!   `constellations/<name>/manifest.yaml`.
34//! - `ConstellationSnapshot`: the materialized cross-Atlas view.
35//! - `init_constellation()`: scaffolds a new Constellation pointing
36//!   at one or more Atlases by `vat_*` id.
37//! - `materialize_constellation()`: reads each composing Atlas's
38//!   snapshot.json, sums findings + events + bridges across, and
39//!   computes a content-addressed composition hash.
40//!
41//! Constellation is read-only. Confirmed bridges live in Atlas;
42//! a `cross_atlas_bridges[]` field on the manifest is
43//! auto-populated by `materialize_constellation` for any
44//! confirmed bridge whose two endpoint frontiers land in
45//! different composing Atlases (v0.82.5).
46
47use std::fs;
48use std::path::{Path, PathBuf};
49
50use chrono::Utc;
51use serde::{Deserialize, Serialize};
52use sha2::{Digest, Sha256};
53
54/// `constellations/<name>/manifest.yaml` schema. Mirrors the
55/// Carina v0.5 `Constellation` primitive.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct ConstellationManifest {
58    /// Should equal `vela.constellation_manifest.v0.1`.
59    pub schema: String,
60    /// Constellation content-addressed id (`vco_*`).
61    pub id: String,
62    /// Human-readable Constellation name.
63    pub name: String,
64    /// Optional bounded-question text describing what cross-domain
65    /// question this Constellation maps.
66    #[serde(default)]
67    pub scope_note: Option<String>,
68    /// Composing Atlases (one or more).
69    pub composing_atlases: Vec<ConstellationAtlasRef>,
70    /// Cross-Atlas bridges (vbr_* ids) where the bridge's
71    /// endpoints span findings in two different composing Atlases.
72    #[serde(default)]
73    pub cross_atlas_bridges: Vec<String>,
74    /// Constellation maintainers.
75    #[serde(default)]
76    pub maintainers: Vec<ConstellationMaintainer>,
77    /// RFC3339 timestamp.
78    pub created_at: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct ConstellationAtlasRef {
83    /// Atlas content-addressed id (`vat_*`).
84    pub vat_id: String,
85    /// Human-readable Atlas name.
86    pub name: String,
87    /// File path to the Atlas's `manifest.yaml`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub locator: Option<String>,
90    /// Optional role (e.g. `core`, `partner-atlas`).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub role: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct ConstellationMaintainer {
97    pub actor_id: String,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub role: Option<String>,
100}
101
102/// The materialized Constellation snapshot.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ConstellationSnapshot {
105    pub schema: String,
106    pub constellation_id: String,
107    pub constellation_name: String,
108    pub generated_at: String,
109    pub atlas_count: usize,
110    pub total_frontiers: usize,
111    pub total_findings: usize,
112    pub total_accepted_core: usize,
113    pub total_events: usize,
114    pub total_bridges: usize,
115    pub cross_atlas_bridges: usize,
116    pub atlases: Vec<ConstellationAtlasSummary>,
117    pub composition_hash: String,
118    /// v0.225: rolled-up substrate counts across composing
119    /// Atlases (each Atlas already sums across its frontiers).
120    #[serde(default)]
121    pub released_diff_pack_count: usize,
122    #[serde(default)]
123    pub verdict_conflict_count: usize,
124    #[serde(default)]
125    pub pending_verdict_count: usize,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ConstellationAtlasSummary {
130    pub vat_id: String,
131    pub name: String,
132    pub frontiers: usize,
133    pub findings: usize,
134    pub accepted_core: usize,
135    pub events: usize,
136    pub bridges: usize,
137    pub role: Option<String>,
138}
139
140/// Initialize a Constellation: scaffold
141/// `constellations/<name>/manifest.yaml` pointing at one or more
142/// existing Atlas dirs.
143pub fn init_constellation(
144    constellations_root: &Path,
145    name: &str,
146    scope_note: Option<&str>,
147    atlas_dirs: &[PathBuf],
148) -> Result<(PathBuf, ConstellationManifest), String> {
149    if atlas_dirs.is_empty() {
150        return Err("init_constellation: at least one Atlas dir is required".to_string());
151    }
152    let dir_name = sanitize_name(name);
153    let dir = constellations_root.join(&dir_name);
154    fs::create_dir_all(&dir)
155        .map_err(|e| format!("create constellation dir {}: {e}", dir.display()))?;
156
157    let mut composing = Vec::with_capacity(atlas_dirs.len());
158    for ad in atlas_dirs {
159        let manifest_path = ad.join("manifest.yaml");
160        let yaml = fs::read_to_string(&manifest_path)
161            .map_err(|e| format!("read atlas manifest {}: {e}", manifest_path.display()))?;
162        let atlas_manifest: vela_atlas::AtlasManifest =
163            serde_yaml::from_str(&yaml).map_err(|e| format!("parse atlas manifest: {e}"))?;
164        composing.push(ConstellationAtlasRef {
165            vat_id: atlas_manifest.id.clone(),
166            name: atlas_manifest.name.clone(),
167            locator: Some(format!("file://{}", manifest_path.display())),
168            role: None,
169        });
170    }
171
172    let id = constellation_id_from_manifest(name, &composing);
173    let manifest = ConstellationManifest {
174        schema: "vela.constellation_manifest.v0.1".to_string(),
175        id,
176        name: name.to_string(),
177        scope_note: scope_note.map(String::from),
178        composing_atlases: composing,
179        cross_atlas_bridges: Vec::new(),
180        maintainers: Vec::new(),
181        created_at: Utc::now().to_rfc3339(),
182    };
183    let manifest_path = dir.join("manifest.yaml");
184    let yaml = serde_yaml::to_string(&manifest).map_err(|e| format!("serialize manifest: {e}"))?;
185    fs::write(&manifest_path, yaml).map_err(|e| format!("write manifest: {e}"))?;
186    Ok((manifest_path, manifest))
187}
188
189/// Materialize a Constellation: read each composing Atlas's
190/// `snapshot.json`, sum findings/events/bridges across, compute
191/// content-addressed composition hash, write
192/// `constellations/<name>/snapshot.json` and a static
193/// `index.html`.
194pub fn materialize_constellation(
195    constellation_dir: &Path,
196) -> Result<(PathBuf, ConstellationSnapshot), String> {
197    let manifest_path = constellation_dir.join("manifest.yaml");
198    let yaml = fs::read_to_string(&manifest_path)
199        .map_err(|e| format!("read manifest {}: {e}", manifest_path.display()))?;
200    let mut manifest: ConstellationManifest =
201        serde_yaml::from_str(&yaml).map_err(|e| format!("parse manifest: {e}"))?;
202
203    // v0.82.5: auto-discover cross-Atlas bridges. Walks each
204    // composing Atlas's confirmed bridges and appends any whose
205    // endpoint frontiers span Atlas boundaries to
206    // `cross_atlas_bridges`. Manifest is re-written if any new
207    // entries are added.
208    let cross_added = sync_cross_atlas_bridges(&mut manifest)?;
209    if cross_added > 0 {
210        let yaml_out = serde_yaml::to_string(&manifest)
211            .map_err(|e| format!("re-serialize manifest after cross-bridge sync: {e}"))?;
212        fs::write(&manifest_path, yaml_out)
213            .map_err(|e| format!("write manifest after cross-bridge sync: {e}"))?;
214    }
215
216    let mut atlas_summaries = Vec::with_capacity(manifest.composing_atlases.len());
217    let mut total_frontiers = 0usize;
218    let mut total_findings = 0usize;
219    let mut total_accepted_core = 0usize;
220    let mut total_events = 0usize;
221    let mut total_bridges = 0usize;
222    // v0.225: roll up the v0.213+ substrate counts.
223    let mut released_diff_pack_count = 0usize;
224    let mut verdict_conflict_count = 0usize;
225    let mut pending_verdict_count = 0usize;
226    for ar in &manifest.composing_atlases {
227        let locator = ar
228            .locator
229            .as_deref()
230            .ok_or_else(|| format!("atlas {} has no locator", ar.name))?;
231        let manifest_path = locator
232            .strip_prefix("file://")
233            .map(PathBuf::from)
234            .ok_or_else(|| format!("atlas locator must be a file:// URL, got '{locator}'"))?;
235        let atlas_dir = manifest_path.parent().ok_or_else(|| {
236            format!(
237                "atlas manifest path has no parent: {}",
238                manifest_path.display()
239            )
240        })?;
241
242        // Materialize the Atlas (or read its existing snapshot).
243        // For substrate honesty, re-materialize on demand so the
244        // Constellation snapshot is always over fresh per-Atlas
245        // data.
246        let (_, atlas_snapshot) = vela_atlas::materialize_atlas(atlas_dir)
247            .map_err(|e| format!("materialize atlas {}: {e}", atlas_dir.display()))?;
248
249        total_frontiers += atlas_snapshot.frontier_count;
250        total_findings += atlas_snapshot.total_findings;
251        total_accepted_core += atlas_snapshot.accepted_core_findings;
252        total_events += atlas_snapshot.total_events;
253        total_bridges += atlas_snapshot.bridge_count;
254        released_diff_pack_count += atlas_snapshot.released_diff_pack_count;
255        verdict_conflict_count += atlas_snapshot.verdict_conflict_count;
256        pending_verdict_count += atlas_snapshot.pending_verdict_count;
257
258        atlas_summaries.push(ConstellationAtlasSummary {
259            vat_id: atlas_snapshot.atlas_id,
260            name: atlas_snapshot.atlas_name,
261            frontiers: atlas_snapshot.frontier_count,
262            findings: atlas_snapshot.total_findings,
263            accepted_core: atlas_snapshot.accepted_core_findings,
264            events: atlas_snapshot.total_events,
265            bridges: atlas_snapshot.bridge_count,
266            role: ar.role.clone(),
267        });
268    }
269
270    let snapshot = ConstellationSnapshot {
271        schema: "vela.constellation_snapshot.v0.1".to_string(),
272        constellation_id: manifest.id.clone(),
273        constellation_name: manifest.name.clone(),
274        generated_at: Utc::now().to_rfc3339(),
275        atlas_count: manifest.composing_atlases.len(),
276        total_frontiers,
277        total_findings,
278        total_accepted_core,
279        total_events,
280        total_bridges,
281        cross_atlas_bridges: manifest.cross_atlas_bridges.len(),
282        atlases: atlas_summaries,
283        composition_hash: composition_hash(&manifest),
284        released_diff_pack_count,
285        verdict_conflict_count,
286        pending_verdict_count,
287    };
288
289    let snapshot_path = constellation_dir.join("snapshot.json");
290    let json =
291        serde_json::to_string_pretty(&snapshot).map_err(|e| format!("serialize snapshot: {e}"))?;
292    fs::write(&snapshot_path, format!("{json}\n")).map_err(|e| format!("write snapshot: {e}"))?;
293
294    let html = render_constellation_html(&manifest, &snapshot);
295    fs::write(constellation_dir.join("index.html"), html)
296        .map_err(|e| format!("write constellation index.html: {e}"))?;
297
298    Ok((snapshot_path, snapshot))
299}
300
301/// Walks the bridges of every composing Atlas and identifies any
302/// confirmed bridge whose two frontier endpoints land in *different*
303/// composing Atlases. Such bridges are recorded in
304/// `manifest.cross_atlas_bridges` (deduped). Returns the number of
305/// newly added entries.
306///
307/// Substrate honesty: bridges remain Atlas-owned; this function only
308/// reads them and records cross-Atlas pointers at the Constellation
309/// layer. No bridge state is rewritten.
310fn sync_cross_atlas_bridges(manifest: &mut ConstellationManifest) -> Result<usize, String> {
311    use serde_json::Value;
312    use std::collections::{HashMap, HashSet};
313
314    // Build vfr_id -> vat_id map and collect each frontier's
315    // candidate `.vela/bridges/` directory.
316    let mut vfr_to_vat: HashMap<String, String> = HashMap::new();
317    let mut bridge_dirs: Vec<PathBuf> = Vec::new();
318
319    for ar in &manifest.composing_atlases {
320        let Some(locator) = ar.locator.as_deref() else {
321            continue;
322        };
323        let Some(atlas_manifest_path) = locator.strip_prefix("file://") else {
324            continue;
325        };
326        let atlas_manifest_path = PathBuf::from(atlas_manifest_path);
327        let yaml = match fs::read_to_string(&atlas_manifest_path) {
328            Ok(y) => y,
329            Err(_) => continue,
330        };
331        let atlas_manifest: vela_atlas::AtlasManifest = match serde_yaml::from_str(&yaml) {
332            Ok(m) => m,
333            Err(_) => continue,
334        };
335        let vat_id = atlas_manifest.id.clone();
336        for fr in &atlas_manifest.composing_frontiers {
337            vfr_to_vat
338                .entry(fr.vfr_id.clone())
339                .or_insert_with(|| vat_id.clone());
340            // Resolve frontier locator to a candidate bridges dir.
341            let Some(loc) = fr.locator.as_deref() else {
342                continue;
343            };
344            let Some(frontier_path) = loc.strip_prefix("file://") else {
345                continue;
346            };
347            let p = PathBuf::from(frontier_path);
348            if p.is_dir() {
349                bridge_dirs.push(p.join(".vela").join("bridges"));
350            } else if let Some(parent) = p.parent() {
351                bridge_dirs.push(parent.join(".vela").join("bridges"));
352            }
353        }
354    }
355
356    if vfr_to_vat.is_empty() {
357        return Ok(0);
358    }
359
360    let already: HashSet<String> = manifest.cross_atlas_bridges.iter().cloned().collect();
361    let mut seen_this_run: HashSet<String> = HashSet::new();
362    let mut added = 0usize;
363
364    // Dedup bridge dirs (multiple frontiers may share a parent).
365    bridge_dirs.sort();
366    bridge_dirs.dedup();
367
368    for dir in &bridge_dirs {
369        if !dir.is_dir() {
370            continue;
371        }
372        let entries = match fs::read_dir(dir) {
373            Ok(e) => e,
374            Err(_) => continue,
375        };
376        for entry in entries.flatten() {
377            let path = entry.path();
378            if path.extension().and_then(|s| s.to_str()) != Some("json") {
379                continue;
380            }
381            let Ok(text) = fs::read_to_string(&path) else {
382                continue;
383            };
384            let Ok(bridge): Result<Value, _> = serde_json::from_str(&text) else {
385                continue;
386            };
387            let id = bridge
388                .get("id")
389                .and_then(Value::as_str)
390                .unwrap_or("")
391                .to_string();
392            if id.is_empty() || already.contains(&id) || seen_this_run.contains(&id) {
393                continue;
394            }
395            let status = bridge.get("status").and_then(Value::as_str).unwrap_or("");
396            if !matches!(status, "confirmed" | "Confirmed") {
397                continue;
398            }
399            let endpoints: Vec<String> = bridge
400                .get("frontier_ids")
401                .and_then(Value::as_array)
402                .map(|arr| {
403                    arr.iter()
404                        .filter_map(Value::as_str)
405                        .map(str::to_string)
406                        .collect()
407                })
408                .unwrap_or_default();
409            if endpoints.len() < 2 {
410                continue;
411            }
412            // Map each endpoint to its vat_id; the bridge is
413            // cross-Atlas iff at least two distinct vat_ids appear
414            // and every endpoint resolves into the Constellation.
415            let mats: Vec<&String> = endpoints.iter().filter_map(|e| vfr_to_vat.get(e)).collect();
416            if mats.len() != endpoints.len() {
417                // Some endpoint isn't in any composing Atlas; skip
418                // (the bridge isn't fully inside this Constellation).
419                continue;
420            }
421            let distinct: HashSet<&&String> = mats.iter().collect();
422            if distinct.len() < 2 {
423                continue;
424            }
425            seen_this_run.insert(id.clone());
426            manifest.cross_atlas_bridges.push(id);
427            added += 1;
428        }
429    }
430
431    Ok(added)
432}
433
434fn composition_hash(manifest: &ConstellationManifest) -> String {
435    let mut h = Sha256::new();
436    h.update(manifest.id.as_bytes());
437    h.update(b"|");
438    for ar in &manifest.composing_atlases {
439        h.update(ar.vat_id.as_bytes());
440        h.update(b",");
441    }
442    h.update(b"|cross_bridges|");
443    for vbr in &manifest.cross_atlas_bridges {
444        h.update(vbr.as_bytes());
445        h.update(b",");
446    }
447    format!("sha256:{}", hex::encode(h.finalize()))
448}
449
450fn constellation_id_from_manifest(name: &str, composing: &[ConstellationAtlasRef]) -> String {
451    let mut h = Sha256::new();
452    h.update(name.as_bytes());
453    h.update(b"|");
454    for ar in composing {
455        h.update(ar.vat_id.as_bytes());
456        h.update(b",");
457    }
458    let digest = h.finalize();
459    let short = hex::encode(&digest[..8]);
460    format!("vco_{short}")
461}
462
463fn sanitize_name(name: &str) -> String {
464    name.chars()
465        .map(|c| {
466            if c.is_ascii_alphanumeric() || c == '-' {
467                c.to_ascii_lowercase()
468            } else {
469                '-'
470            }
471        })
472        .collect()
473}
474
475fn render_constellation_html(
476    manifest: &ConstellationManifest,
477    snapshot: &ConstellationSnapshot,
478) -> String {
479    let mut atlases_html = String::new();
480    for a in &snapshot.atlases {
481        let role = a.role.as_deref().unwrap_or("");
482        let role_html = if role.is_empty() {
483            String::new()
484        } else {
485            format!(" <span class=\"role\">{role}</span>")
486        };
487        atlases_html.push_str(&format!(
488            "<li><strong>{name}</strong>{role_html}<br/><code>{vat}</code> · {frontiers} frontiers, {findings} findings ({accepted} accepted-core), {events} events, {bridges} bridges</li>",
489            name = html_escape(&a.name),
490            vat = html_escape(&a.vat_id),
491            frontiers = a.frontiers,
492            findings = a.findings,
493            accepted = a.accepted_core,
494            events = a.events,
495            bridges = a.bridges,
496        ));
497    }
498    let scope = match manifest.scope_note.as_deref() {
499        Some(text) if !text.is_empty() => {
500            format!("<p class=\"scope\">{}</p>", html_escape(text))
501        }
502        _ => String::new(),
503    };
504    format!(
505        r#"<!doctype html>
506<html lang="en">
507<head>
508<meta charset="utf-8">
509<title>{name} · Vela Constellation</title>
510<meta name="viewport" content="width=device-width,initial-scale=1">
511<style>
512  body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1.4rem; color: #222; line-height: 1.55; }}
513  h1 {{ font-size: 1.4rem; margin: 0 0 0.4rem 0; }}
514  h2 {{ font-size: 1.05rem; margin: 1.6rem 0 0.5rem 0; border-bottom: 1px solid #eee; padding-bottom: 0.2rem; }}
515  .meta {{ color: #666; font-size: 0.92em; }}
516  .scope {{ background: #f7f5f0; border-left: 3px solid #4a7c59; padding: 0.6rem 0.9rem; margin: 0.8rem 0; }}
517  code {{ background: #f5f2ec; padding: 0.05em 0.35em; border-radius: 2px; font-size: 0.9em; }}
518  ul {{ padding-left: 1.4rem; }}
519  li {{ margin: 0.4rem 0; }}
520  .role {{ color: #888; font-size: 0.85em; font-style: italic; }}
521  table {{ border-collapse: collapse; margin: 0.6rem 0; }}
522  td {{ padding: 0.2rem 0.8rem 0.2rem 0; vertical-align: top; }}
523  td.k {{ color: #666; }}
524  footer {{ margin-top: 2rem; color: #999; font-size: 0.85em; }}
525</style>
526</head>
527<body>
528<h1>{name}</h1>
529<div class="meta">{vco}</div>
530{scope}
531
532<h2>Composition</h2>
533<table>
534<tr><td class="k">atlases</td><td>{atlases}</td></tr>
535<tr><td class="k">total frontiers</td><td>{frontiers}</td></tr>
536<tr><td class="k">total findings</td><td>{findings}</td></tr>
537<tr><td class="k">accepted-core findings</td><td>{accepted}</td></tr>
538<tr><td class="k">total events</td><td>{events}</td></tr>
539<tr><td class="k">total bridges (manifest)</td><td>{bridges}</td></tr>
540<tr><td class="k">cross-Atlas bridges</td><td>{cross}</td></tr>
541<tr><td class="k">composition hash</td><td><code>{hash}</code></td></tr>
542<tr><td class="k">generated at</td><td>{ts}</td></tr>
543</table>
544
545<h2>Composing Atlases</h2>
546<ul>
547{atlases_html}
548</ul>
549
550<footer>
551Vela Constellation v0.81 · <a href="https://github.com/vela-science/vela">github.com/vela-science/vela</a>
552</footer>
553</body>
554</html>
555"#,
556        name = html_escape(&manifest.name),
557        vco = html_escape(&manifest.id),
558        scope = scope,
559        atlases = snapshot.atlas_count,
560        frontiers = snapshot.total_frontiers,
561        findings = snapshot.total_findings,
562        accepted = snapshot.total_accepted_core,
563        events = snapshot.total_events,
564        bridges = snapshot.total_bridges,
565        cross = snapshot.cross_atlas_bridges,
566        hash = html_escape(&snapshot.composition_hash),
567        ts = html_escape(&snapshot.generated_at),
568        atlases_html = atlases_html,
569    )
570}
571
572fn html_escape(s: &str) -> String {
573    s.replace('&', "&amp;")
574        .replace('<', "&lt;")
575        .replace('>', "&gt;")
576        .replace('"', "&quot;")
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use tempfile::tempdir;
583
584    #[test]
585    fn constellation_id_is_content_addressed() {
586        let composing = vec![
587            ConstellationAtlasRef {
588                vat_id: "vat_aaaa".to_string(),
589                name: "a".to_string(),
590                locator: None,
591                role: None,
592            },
593            ConstellationAtlasRef {
594                vat_id: "vat_bbbb".to_string(),
595                name: "b".to_string(),
596                locator: None,
597                role: None,
598            },
599        ];
600        let id1 = constellation_id_from_manifest("Demo", &composing);
601        let id2 = constellation_id_from_manifest("Demo", &composing);
602        assert_eq!(id1, id2);
603        let id3 = constellation_id_from_manifest("Other", &composing);
604        assert_ne!(id1, id3);
605    }
606
607    #[test]
608    fn init_constellation_writes_manifest() {
609        let dir = tempdir().expect("tempdir");
610        let constellations = dir.path().join("constellations");
611
612        // Build a fake Atlas dir with manifest.yaml so init can
613        // load it.
614        let atlas_dir = dir.path().join("atlas-a");
615        fs::create_dir_all(&atlas_dir).unwrap();
616        let atlas_manifest = vela_atlas::AtlasManifest {
617            schema: "vela.atlas_manifest.v0.1".to_string(),
618            id: "vat_test".to_string(),
619            name: "Test Atlas".to_string(),
620            domain: "demo".to_string(),
621            scope_note: None,
622            composing_frontiers: vec![],
623            bridges: vec![],
624            maintainers: vec![],
625            review_policy_locator: None,
626            created_at: Utc::now().to_rfc3339(),
627        };
628        let yaml = serde_yaml::to_string(&atlas_manifest).unwrap();
629        fs::write(atlas_dir.join("manifest.yaml"), yaml).unwrap();
630
631        let (manifest_path, manifest) = init_constellation(
632            &constellations,
633            "demo-constellation",
634            Some("test scope"),
635            &[atlas_dir],
636        )
637        .expect("init");
638
639        assert!(manifest_path.is_file());
640        assert!(manifest.id.starts_with("vco_"));
641        assert_eq!(manifest.composing_atlases.len(), 1);
642        assert_eq!(manifest.composing_atlases[0].vat_id, "vat_test");
643    }
644}