Skip to main content

arete_artifacts/
lib.rs

1//! Versioned public artifacts shared by Arete generators, clients, and the
2//! hosted control plane.
3//!
4//! Composite stack ASTs are accepted only through [`decompose_legacy_stack`]
5//! and [`normalize_legacy_stack_v2`]. New authoring should construct typed V2
6//! artifacts with [`author_stack_v2`] instead.
7
8mod authoring;
9mod live;
10mod manifest;
11
12pub use authoring::*;
13pub use live::*;
14pub use manifest::*;
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use arete_hash::{
19    canonicalize_jcs, hash_jcs, hash_raw_bytes, parse_json_bytes_strict, ArtifactFile, HashError,
20    HashId, LiveSpec, ProgramSpec, ProgramSpecV1, StackManifest,
21};
22use serde::{de::DeserializeOwned, Deserialize, Serialize};
23use serde_json::{json, Map, Value};
24use thiserror::Error;
25
26pub const ARTIFACT_VERSION_V1: &str = "1.0.0";
27pub const PROGRAM_SPEC_KIND: &str = "program-spec";
28pub const LIVE_SPEC_KIND: &str = "live-spec";
29pub const STACK_MANIFEST_KIND: &str = "stack-manifest";
30pub const LIVE_SPEC_SCHEMA_V1: &str = "arete.live-spec/v1";
31pub const STACK_MANIFEST_SCHEMA_V1: &str = "arete.stack-manifest/v1";
32pub const LIVE_SPEC_SCHEMA_V2: &str = "arete.live-spec/v2";
33pub const STACK_MANIFEST_SCHEMA_V2: &str = "arete.stack-manifest/v2";
34pub const LEGACY_NORMALIZER_CONTRACT_V1: &str = "arete.legacy-stack-normalizer/v1";
35pub const LIVE_COMPILER_CONTRACT_V1: &str = "arete-live-compiler/v1";
36pub const LIVE_WIRE_CONTRACT_V1: &str = "arete-live-wire/v1";
37
38pub const CURRENT_AST_VERSION: &str = "0.0.5";
39pub const COMPATIBLE_AST_VERSIONS: &[&str] = &["0.0.1", "0.0.2", "0.0.3", "0.0.4"];
40
41#[derive(Debug, Error)]
42pub enum ArtifactError {
43    #[error(transparent)]
44    Hash(#[from] HashError),
45    #[error("invalid artifact JSON: {0}")]
46    InvalidJson(String),
47    #[error("unsupported {artifact} version '{version}'")]
48    UnsupportedVersion {
49        artifact: &'static str,
50        version: String,
51    },
52    #[error("artifact kind must be '{expected}', not '{actual}'")]
53    WrongKind {
54        expected: &'static str,
55        actual: String,
56    },
57    #[error("artifact hash does not match its payload")]
58    HashMismatch,
59    #[error("invalid legacy stack: {0}")]
60    InvalidLegacyStack(String),
61    #[error("invalid artifact: {0}")]
62    InvalidArtifact(String),
63    #[error("public artifact contains private field '{0}'")]
64    PrivateField(String),
65    #[error("artifact I/O failed: {0}")]
66    Io(#[from] std::io::Error),
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct ProgramSpecArtifact {
72    pub artifact_version: String,
73    pub kind: String,
74    pub artifact_hash: HashId<ProgramSpec>,
75    pub payload: ProgramSpecV1,
76}
77
78impl ProgramSpecArtifact {
79    pub fn new(payload: ProgramSpecV1) -> Result<Self, ArtifactError> {
80        let artifact_hash = payload.hash()?;
81        Ok(Self {
82            artifact_version: ARTIFACT_VERSION_V1.to_string(),
83            kind: PROGRAM_SPEC_KIND.to_string(),
84            artifact_hash,
85            payload,
86        })
87    }
88
89    pub fn validate(&self) -> Result<(), ArtifactError> {
90        validate_envelope_version(&self.artifact_version, PROGRAM_SPEC_KIND)?;
91        validate_kind(&self.kind, PROGRAM_SPEC_KIND)?;
92        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
93        if self.payload.hash()? != self.artifact_hash {
94            return Err(ArtifactError::HashMismatch);
95        }
96        Ok(())
97    }
98
99    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
100        self.validate()?;
101        canonicalize_jcs(self).map_err(Into::into)
102    }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct ProgramRequirementV1 {
108    pub program_id: String,
109    pub program_spec_hash: HashId<ProgramSpec>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase", deny_unknown_fields)]
114pub struct LegacyProgramExtensionsV1 {
115    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
116    pub pdas: BTreeMap<String, Value>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub instructions: Vec<Value>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub struct LiveSpecV1 {
124    pub schema: String,
125    pub compiler_contract_version: String,
126    pub wire_contract_version: String,
127    pub programs: Vec<ProgramRequirementV1>,
128    pub entities: Vec<Value>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub legacy_program_extensions: Option<LegacyProgramExtensionsV1>,
131}
132
133impl LiveSpecV1 {
134    pub fn validate(&self) -> Result<(), ArtifactError> {
135        if self.schema != LIVE_SPEC_SCHEMA_V1 {
136            return Err(ArtifactError::UnsupportedVersion {
137                artifact: LIVE_SPEC_KIND,
138                version: self.schema.clone(),
139            });
140        }
141        if self.compiler_contract_version.is_empty() || self.wire_contract_version.is_empty() {
142            return Err(ArtifactError::InvalidLegacyStack(
143                "live compiler and wire contract versions must not be empty".to_string(),
144            ));
145        }
146        let mut program_hashes = BTreeSet::new();
147        for program in &self.programs {
148            if program.program_id.is_empty()
149                || !program_hashes.insert(program.program_spec_hash.to_string())
150            {
151                return Err(ArtifactError::InvalidLegacyStack(
152                    "live program requirements must have unique hashes and non-empty program IDs"
153                        .to_string(),
154                ));
155            }
156        }
157        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)
158    }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct LiveSpecArtifact {
164    pub artifact_version: String,
165    pub kind: String,
166    pub artifact_hash: HashId<LiveSpec>,
167    pub payload: LiveSpecV1,
168}
169
170impl LiveSpecArtifact {
171    pub fn new(payload: LiveSpecV1) -> Result<Self, ArtifactError> {
172        payload.validate()?;
173        let artifact_hash = hash_live_projection(ARTIFACT_VERSION_V1, &payload)?;
174        Ok(Self {
175            artifact_version: ARTIFACT_VERSION_V1.to_string(),
176            kind: LIVE_SPEC_KIND.to_string(),
177            artifact_hash,
178            payload,
179        })
180    }
181
182    pub fn validate(&self) -> Result<(), ArtifactError> {
183        validate_envelope_version(&self.artifact_version, LIVE_SPEC_KIND)?;
184        validate_kind(&self.kind, LIVE_SPEC_KIND)?;
185        self.payload.validate()?;
186        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
187        let expected = hash_live_projection(&self.artifact_version, &self.payload)?;
188        if expected != self.artifact_hash {
189            return Err(ArtifactError::HashMismatch);
190        }
191        Ok(())
192    }
193
194    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
195        self.validate()?;
196        canonicalize_jcs(self).map_err(Into::into)
197    }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase", deny_unknown_fields)]
202pub struct ProgramSpecReferenceV1 {
203    pub program_id: String,
204    pub artifact_hash: HashId<ProgramSpec>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct LiveSpecReferenceV1 {
210    pub artifact_hash: HashId<LiveSpec>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215pub struct SelectedViewV1 {
216    pub live_spec_hash: HashId<LiveSpec>,
217    pub view_id: String,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222pub struct StackManifestV1 {
223    pub schema: String,
224    pub name: String,
225    pub programs: Vec<ProgramSpecReferenceV1>,
226    pub live_specs: Vec<LiveSpecReferenceV1>,
227    pub selected_views: Vec<SelectedViewV1>,
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub queries: Vec<Value>,
230    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
231    pub extensions: BTreeMap<String, Value>,
232    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
233    pub metadata: BTreeMap<String, Value>,
234}
235
236impl StackManifestV1 {
237    pub fn validate(&self) -> Result<(), ArtifactError> {
238        if self.schema != STACK_MANIFEST_SCHEMA_V1 {
239            return Err(ArtifactError::UnsupportedVersion {
240                artifact: STACK_MANIFEST_KIND,
241                version: self.schema.clone(),
242            });
243        }
244        if self.name.is_empty() {
245            return Err(ArtifactError::InvalidLegacyStack(
246                "stack manifest name must not be empty".to_string(),
247            ));
248        }
249        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct StackManifestArtifact {
256    pub artifact_version: String,
257    pub kind: String,
258    pub artifact_hash: HashId<StackManifest>,
259    pub payload: StackManifestV1,
260}
261
262impl StackManifestArtifact {
263    pub fn new(payload: StackManifestV1) -> Result<Self, ArtifactError> {
264        payload.validate()?;
265        let artifact_hash = hash_manifest_projection(ARTIFACT_VERSION_V1, &payload)?;
266        Ok(Self {
267            artifact_version: ARTIFACT_VERSION_V1.to_string(),
268            kind: STACK_MANIFEST_KIND.to_string(),
269            artifact_hash,
270            payload,
271        })
272    }
273
274    pub fn validate(&self) -> Result<(), ArtifactError> {
275        validate_envelope_version(&self.artifact_version, STACK_MANIFEST_KIND)?;
276        validate_kind(&self.kind, STACK_MANIFEST_KIND)?;
277        self.payload.validate()?;
278        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
279        let expected = hash_manifest_projection(&self.artifact_version, &self.payload)?;
280        if expected != self.artifact_hash {
281            return Err(ArtifactError::HashMismatch);
282        }
283        Ok(())
284    }
285
286    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
287        self.validate()?;
288        canonicalize_jcs(self).map_err(Into::into)
289    }
290}
291
292#[derive(Debug, Clone)]
293pub struct LoadedArtifact<A> {
294    pub artifact: A,
295    pub original_bytes: Vec<u8>,
296    pub source_hash: HashId<ArtifactFile>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct LegacySourceProvenance {
302    pub source_hash: HashId<ArtifactFile>,
303    pub declared_ast_version: Option<String>,
304    pub normalized_ast_version: String,
305    pub legacy_content_hash: Option<String>,
306    pub normalizer_contract_version: String,
307}
308
309#[derive(Debug, Clone)]
310pub struct LegacyDecomposition {
311    pub source: LegacySourceProvenance,
312    pub program_specs: Vec<ProgramSpecArtifact>,
313    pub live_spec: LiveSpecArtifact,
314    pub stack_manifest: StackManifestArtifact,
315}
316
317pub fn load_program_spec(
318    bytes: &[u8],
319) -> Result<LoadedArtifact<ProgramSpecArtifact>, ArtifactError> {
320    load_artifact(bytes, ProgramSpecArtifact::validate)
321}
322
323pub fn load_live_spec(bytes: &[u8]) -> Result<LoadedArtifact<LiveSpecArtifact>, ArtifactError> {
324    load_artifact(bytes, LiveSpecArtifact::validate)
325}
326
327pub fn load_stack_manifest(
328    bytes: &[u8],
329) -> Result<LoadedArtifact<StackManifestArtifact>, ArtifactError> {
330    load_artifact(bytes, StackManifestArtifact::validate)
331}
332
333pub fn load_legacy_stack_value(bytes: &[u8]) -> Result<LoadedArtifact<Value>, ArtifactError> {
334    let mut value = parse_json_bytes_strict(bytes)?;
335    reject_private_fields(&value)?;
336    let object = value.as_object_mut().ok_or_else(|| {
337        ArtifactError::InvalidLegacyStack("top-level value must be an object".to_string())
338    })?;
339    let declared_ast_version = object
340        .get("ast_version")
341        .and_then(Value::as_str)
342        .unwrap_or("0.0.1");
343    if declared_ast_version != CURRENT_AST_VERSION
344        && !COMPATIBLE_AST_VERSIONS.contains(&declared_ast_version)
345    {
346        return Err(ArtifactError::UnsupportedVersion {
347            artifact: "legacy-stack",
348            version: declared_ast_version.to_string(),
349        });
350    }
351    object.insert(
352        "ast_version".to_string(),
353        Value::String(CURRENT_AST_VERSION.to_string()),
354    );
355    Ok(LoadedArtifact {
356        artifact: value,
357        original_bytes: bytes.to_vec(),
358        source_hash: hash_raw_bytes::<ArtifactFile>(bytes)?,
359    })
360}
361
362/// Deterministically decompose the supported composite stack AST into public
363/// artifacts. Historical ASTs that predate embedded `ProgramSpecV1` values must
364/// first be enriched from retained IDL provenance; this adapter never fabricates
365/// modern IDL hashes from a lossy snapshot.
366pub fn decompose_legacy_stack(bytes: &[u8]) -> Result<LegacyDecomposition, ArtifactError> {
367    let loaded = load_legacy_stack_value(bytes)?;
368    let original: Value = parse_json_bytes_strict(bytes)?;
369    let declared_ast_version = original
370        .get("ast_version")
371        .and_then(Value::as_str)
372        .map(str::to_string);
373    let legacy_content_hash = original
374        .get("content_hash")
375        .and_then(Value::as_str)
376        .map(str::to_string);
377    let stack = loaded.artifact.as_object().ok_or_else(|| {
378        ArtifactError::InvalidLegacyStack("top-level value must be an object".to_string())
379    })?;
380    let stack_name = required_string(stack, "stack_name")?;
381    let program_ids = string_array(stack.get("program_ids"), "program_ids")?;
382    let program_specs_value = stack
383        .get("program_specs")
384        .cloned()
385        .unwrap_or_else(|| Value::Array(Vec::new()));
386    let program_spec_payloads: Vec<ProgramSpecV1> =
387        serde_json::from_value(program_specs_value).map_err(json_error)?;
388    if program_ids.len() != program_spec_payloads.len() {
389        return Err(ArtifactError::InvalidLegacyStack(format!(
390            "program_ids has {} entries but program_specs has {}; historical inputs require exact ProgramSpec resolution from retained IDLs",
391            program_ids.len(),
392            program_spec_payloads.len()
393        )));
394    }
395
396    let mut program_specs = Vec::with_capacity(program_spec_payloads.len());
397    for (program_id, payload) in program_ids.iter().zip(program_spec_payloads) {
398        if payload.program_id != *program_id {
399            return Err(ArtifactError::InvalidLegacyStack(format!(
400                "ProgramSpec program ID '{}' does not match ordered program ID '{}'",
401                payload.program_id, program_id
402            )));
403        }
404        program_specs.push(ProgramSpecArtifact::new(payload)?);
405    }
406
407    let mut entities = stack
408        .get("entities")
409        .and_then(Value::as_array)
410        .cloned()
411        .ok_or_else(|| {
412            ArtifactError::InvalidLegacyStack("entities must be an array".to_string())
413        })?;
414    let mut selected_view_ids = Vec::new();
415    for entity in &mut entities {
416        normalize_entity(entity, &mut selected_view_ids)?;
417    }
418
419    let programs = program_specs
420        .iter()
421        .map(|artifact| ProgramRequirementV1 {
422            program_id: artifact.payload.program_id.clone(),
423            program_spec_hash: artifact.artifact_hash,
424        })
425        .collect();
426    let pdas = value_map(stack.get("pdas"), "pdas")?;
427    let instructions = stack
428        .get("instructions")
429        .and_then(Value::as_array)
430        .cloned()
431        .unwrap_or_default();
432    let legacy_program_extensions = if pdas.is_empty() && instructions.is_empty() {
433        None
434    } else {
435        Some(LegacyProgramExtensionsV1 { pdas, instructions })
436    };
437    let live_spec = LiveSpecArtifact::new(LiveSpecV1 {
438        schema: LIVE_SPEC_SCHEMA_V1.to_string(),
439        compiler_contract_version: LIVE_COMPILER_CONTRACT_V1.to_string(),
440        wire_contract_version: LIVE_WIRE_CONTRACT_V1.to_string(),
441        programs,
442        entities,
443        legacy_program_extensions,
444    })?;
445
446    let stack_manifest = StackManifestArtifact::new(StackManifestV1 {
447        schema: STACK_MANIFEST_SCHEMA_V1.to_string(),
448        name: stack_name,
449        programs: program_specs
450            .iter()
451            .map(|artifact| ProgramSpecReferenceV1 {
452                program_id: artifact.payload.program_id.clone(),
453                artifact_hash: artifact.artifact_hash,
454            })
455            .collect(),
456        live_specs: vec![LiveSpecReferenceV1 {
457            artifact_hash: live_spec.artifact_hash,
458        }],
459        selected_views: selected_view_ids
460            .into_iter()
461            .map(|view_id| SelectedViewV1 {
462                live_spec_hash: live_spec.artifact_hash,
463                view_id,
464            })
465            .collect(),
466        queries: Vec::new(),
467        extensions: BTreeMap::new(),
468        metadata: BTreeMap::new(),
469    })?;
470
471    Ok(LegacyDecomposition {
472        source: LegacySourceProvenance {
473            source_hash: loaded.source_hash,
474            declared_ast_version,
475            normalized_ast_version: CURRENT_AST_VERSION.to_string(),
476            legacy_content_hash,
477            normalizer_contract_version: LEGACY_NORMALIZER_CONTRACT_V1.to_string(),
478        },
479        program_specs,
480        live_spec,
481        stack_manifest,
482    })
483}
484
485fn load_artifact<A: DeserializeOwned>(
486    bytes: &[u8],
487    validate: impl FnOnce(&A) -> Result<(), ArtifactError>,
488) -> Result<LoadedArtifact<A>, ArtifactError> {
489    let value = parse_json_bytes_strict(bytes)?;
490    let artifact = serde_json::from_value(value).map_err(json_error)?;
491    validate(&artifact)?;
492    Ok(LoadedArtifact {
493        artifact,
494        original_bytes: bytes.to_vec(),
495        source_hash: hash_raw_bytes::<ArtifactFile>(bytes)?,
496    })
497}
498
499#[derive(Serialize)]
500#[serde(rename_all = "camelCase")]
501struct ArtifactProjection<'a, P> {
502    artifact_version: &'a str,
503    kind: &'static str,
504    payload: &'a P,
505}
506
507fn hash_live_projection(
508    artifact_version: &str,
509    payload: &LiveSpecV1,
510) -> Result<HashId<LiveSpec>, ArtifactError> {
511    hash_jcs(&ArtifactProjection {
512        artifact_version,
513        kind: LIVE_SPEC_KIND,
514        payload,
515    })
516    .map_err(Into::into)
517}
518
519fn hash_manifest_projection(
520    artifact_version: &str,
521    payload: &StackManifestV1,
522) -> Result<HashId<StackManifest>, ArtifactError> {
523    hash_jcs(&ArtifactProjection {
524        artifact_version,
525        kind: STACK_MANIFEST_KIND,
526        payload,
527    })
528    .map_err(Into::into)
529}
530
531pub(crate) fn validate_envelope_version(
532    version: &str,
533    artifact: &'static str,
534) -> Result<(), ArtifactError> {
535    let components = version.split('.').collect::<Vec<_>>();
536    let valid = components.len() == 3
537        && components.iter().all(|component| {
538            !component.is_empty() && component.chars().all(|c| c.is_ascii_digit())
539        });
540    if !valid || components[0] != "1" {
541        return Err(ArtifactError::UnsupportedVersion {
542            artifact,
543            version: version.to_string(),
544        });
545    }
546    Ok(())
547}
548
549pub(crate) fn validate_kind(actual: &str, expected: &'static str) -> Result<(), ArtifactError> {
550    if actual != expected {
551        return Err(ArtifactError::WrongKind {
552            expected,
553            actual: actual.to_string(),
554        });
555    }
556    Ok(())
557}
558
559fn normalize_entity(
560    entity: &mut Value,
561    selected_view_ids: &mut Vec<String>,
562) -> Result<(), ArtifactError> {
563    let object = entity.as_object_mut().ok_or_else(|| {
564        ArtifactError::InvalidLegacyStack("every entity must be an object".to_string())
565    })?;
566    object.remove("ast_version");
567    object.remove("idl");
568    object.remove("content_hash");
569    let state_name = required_string(object, "state_name")?;
570    let primary_keys = object
571        .get("identity")
572        .and_then(Value::as_object)
573        .and_then(|identity| identity.get("primary_keys"))
574        .and_then(Value::as_array)
575        .ok_or_else(|| {
576            ArtifactError::InvalidLegacyStack(format!(
577                "entity '{state_name}' must declare identity.primary_keys"
578            ))
579        })?;
580    let primary_key = primary_keys
581        .first()
582        .and_then(Value::as_str)
583        .map(str::to_string)
584        .ok_or_else(|| {
585            ArtifactError::InvalidLegacyStack(format!(
586                "entity '{state_name}' must declare a string primary key"
587            ))
588        })?;
589    let views = object
590        .entry("views".to_string())
591        .or_insert_with(|| Value::Array(Vec::new()))
592        .as_array_mut()
593        .ok_or_else(|| {
594            ArtifactError::InvalidLegacyStack(format!(
595                "entity '{state_name}' views must be an array"
596            ))
597        })?;
598    let default_views = [
599        json!({
600            "id": format!("{state_name}/state"),
601            "source": { "Entity": { "name": state_name } },
602            "pipeline": [],
603            "output": { "Keyed": { "key_field": {
604                "segments": primary_key.split('.').collect::<Vec<_>>(),
605                "offsets": null
606            } } }
607        }),
608        json!({
609            "id": format!("{state_name}/list"),
610            "source": { "Entity": { "name": state_name } },
611            "pipeline": [],
612            "output": "Collection"
613        }),
614    ];
615    for expected in default_views {
616        let expected_id = expected["id"].as_str().expect("view ID");
617        match views
618            .iter()
619            .find(|view| view.get("id").and_then(Value::as_str) == Some(expected_id))
620        {
621            Some(existing) if existing != &expected => {
622                return Err(ArtifactError::InvalidLegacyStack(format!(
623                    "entity '{state_name}' defines conflicting default view '{expected_id}'"
624                )));
625            }
626            Some(_) => {}
627            None => views.push(expected),
628        }
629    }
630    for view in views {
631        let view_id = view.get("id").and_then(Value::as_str).ok_or_else(|| {
632            ArtifactError::InvalidLegacyStack(format!(
633                "entity '{state_name}' contains a view without an ID"
634            ))
635        })?;
636        selected_view_ids.push(view_id.to_string());
637    }
638    Ok(())
639}
640
641fn required_string(object: &Map<String, Value>, field: &str) -> Result<String, ArtifactError> {
642    object
643        .get(field)
644        .and_then(Value::as_str)
645        .filter(|value| !value.is_empty())
646        .map(str::to_string)
647        .ok_or_else(|| {
648            ArtifactError::InvalidLegacyStack(format!("{field} must be a non-empty string"))
649        })
650}
651
652fn string_array(value: Option<&Value>, field: &str) -> Result<Vec<String>, ArtifactError> {
653    let Some(value) = value else {
654        return Ok(Vec::new());
655    };
656    value
657        .as_array()
658        .ok_or_else(|| ArtifactError::InvalidLegacyStack(format!("{field} must be an array")))?
659        .iter()
660        .map(|entry| {
661            entry.as_str().map(str::to_string).ok_or_else(|| {
662                ArtifactError::InvalidLegacyStack(format!("{field} must contain only strings"))
663            })
664        })
665        .collect()
666}
667
668fn value_map(value: Option<&Value>, field: &str) -> Result<BTreeMap<String, Value>, ArtifactError> {
669    let Some(value) = value else {
670        return Ok(BTreeMap::new());
671    };
672    let object = value
673        .as_object()
674        .ok_or_else(|| ArtifactError::InvalidLegacyStack(format!("{field} must be an object")))?;
675    Ok(object
676        .iter()
677        .map(|(key, value)| (key.clone(), value.clone()))
678        .collect())
679}
680
681pub(crate) fn reject_private_fields(value: &Value) -> Result<(), ArtifactError> {
682    const FORBIDDEN: &[&str] = &[
683        "platform_parser",
684        "platformParser",
685        "platform_decoder_bundle",
686        "platformDecoderBundle",
687        "augmented_specs",
688        "augmentedSpecs",
689        "decoder_binding_id",
690        "decoderBindingId",
691        "decoder_content_hash",
692        "decoderContentHash",
693        "artifact_ref",
694        "artifactRef",
695    ];
696    match value {
697        Value::Object(object) => {
698            for (key, nested) in object {
699                if FORBIDDEN.contains(&key.as_str()) {
700                    return Err(ArtifactError::PrivateField(key.clone()));
701                }
702                reject_private_fields(nested)?;
703            }
704        }
705        Value::Array(values) => {
706            for nested in values {
707                reject_private_fields(nested)?;
708            }
709        }
710        _ => {}
711    }
712    Ok(())
713}
714
715pub(crate) fn json_error(error: serde_json::Error) -> ArtifactError {
716    ArtifactError::InvalidJson(error.to_string())
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use arete_hash::{CanonicalIdlDocument, HashId};
723
724    fn program_spec() -> ProgramSpecV1 {
725        let idl = br#"{
726          "address":"11111111111111111111111111111111",
727          "metadata":{"name":"system","version":"1.0.0","spec":"0.1.0"},
728          "instructions":[],"accounts":[],"types":[],"events":[],"errors":[]
729        }"#;
730        let document = CanonicalIdlDocument::parse(idl, None).expect("canonical IDL");
731        ProgramSpecV1::from_document(&document)
732    }
733
734    fn legacy_stack() -> Vec<u8> {
735        serde_json::to_vec(&json!({
736            "ast_version": "0.0.5",
737            "stack_name": "SystemStack",
738            "program_ids": ["11111111111111111111111111111111"],
739            "idls": [],
740            "program_specs": [program_spec()],
741            "entities": [{
742                "ast_version": "0.0.5",
743                "state_name": "SystemState",
744                "program_id": "11111111111111111111111111111111",
745                "idl": null,
746                "identity": {"primary_keys": ["id.address"], "lookup_indexes": []},
747                "handlers": [],
748                "sections": [],
749                "field_mappings": {},
750                "resolver_hooks": [],
751                "instruction_hooks": [],
752                "resolver_specs": [],
753                "computed_fields": [],
754                "computed_field_specs": [],
755                "content_hash": "legacy-entity-hash",
756                "views": []
757            }],
758            "pdas": {},
759            "instructions": [],
760            "content_hash": "legacy-stack-hash"
761        }))
762        .expect("legacy JSON")
763    }
764
765    #[test]
766    fn program_spec_envelope_preserves_frozen_program_hash() {
767        let payload = program_spec();
768        let expected = payload.hash().expect("ProgramSpec hash");
769        let artifact = ProgramSpecArtifact::new(payload).expect("artifact");
770        assert_eq!(artifact.artifact_hash, expected);
771        artifact.validate().expect("valid artifact");
772    }
773
774    #[test]
775    fn decomposition_is_deterministic_and_adds_explicit_default_views() {
776        let bytes = legacy_stack();
777        let first = decompose_legacy_stack(&bytes).expect("first decomposition");
778        let second = decompose_legacy_stack(&bytes).expect("second decomposition");
779        assert_eq!(
780            first.live_spec.artifact_hash,
781            second.live_spec.artifact_hash
782        );
783        assert_eq!(
784            first.stack_manifest.artifact_hash,
785            second.stack_manifest.artifact_hash
786        );
787        assert_eq!(
788            first.program_specs[0].artifact_hash,
789            program_spec().hash().unwrap()
790        );
791        assert_eq!(first.stack_manifest.payload.selected_views.len(), 2);
792        assert!(first.live_spec.payload.entities[0].get("idl").is_none());
793        assert!(first.live_spec.payload.entities[0]
794            .get("content_hash")
795            .is_none());
796    }
797
798    #[test]
799    fn loaders_reject_unknown_major_wrong_hash_and_private_fields() {
800        let decomposition = decompose_legacy_stack(&legacy_stack()).expect("decomposition");
801        let mut value = serde_json::to_value(&decomposition.live_spec).unwrap();
802        value["artifactVersion"] = Value::String("2.0.0".to_string());
803        assert!(matches!(
804            load_live_spec(&serde_json::to_vec(&value).unwrap()),
805            Err(ArtifactError::UnsupportedVersion { .. })
806        ));
807
808        value["artifactVersion"] = Value::String("1.0.0".to_string());
809        value["artifactHash"] = Value::String(HashId::<LiveSpec>::from_digest([7; 32]).to_string());
810        assert!(matches!(
811            load_live_spec(&serde_json::to_vec(&value).unwrap()),
812            Err(ArtifactError::HashMismatch)
813        ));
814
815        let mut private_stack: Value = serde_json::from_slice(&legacy_stack()).unwrap();
816        private_stack["entities"][0]["decoderBindingId"] = Value::String("private".into());
817        assert!(matches!(
818            decompose_legacy_stack(&serde_json::to_vec(&private_stack).unwrap()),
819            Err(ArtifactError::PrivateField(_))
820        ));
821    }
822
823    #[test]
824    fn exact_input_bytes_are_preserved_for_audit() {
825        let bytes = legacy_stack();
826        let loaded = load_legacy_stack_value(&bytes).expect("legacy source");
827        assert_eq!(loaded.original_bytes, bytes);
828        assert_eq!(
829            loaded.source_hash,
830            hash_raw_bytes::<ArtifactFile>(&loaded.original_bytes).unwrap()
831        );
832    }
833}