wyrd-spec 0.2.0

Wyrd foundation types: Card envelope, SpecKinds, and pure data contracts.
//! Composite card declaration request and response contracts.

use serde::{Deserialize, Serialize};
use url::Url;

use crate::api_version::ApiVersion;
use crate::envelope::{CardKind, Metadata};
use crate::reference::CardRef;
use crate::registry::{
    CardLifecycleStatus, CardUploadEntry, DeclarationOutcomeKind, RelativeArtifactPath,
};

/// Compute the stable BLAKE3 hash for an artifact manifest regardless of its authored order.
///
/// Declaration treats artifact files as a path-keyed publication. Sorting a
/// cloned manifest by its validated relative path before JCS serialization
/// makes equivalent manifests produce the same durable hash without mutating
/// the caller's request order.
///
/// # Errors
///
/// Returns [`crate::error::WyrdError`] when JCS cannot serialize a manifest
/// entry into the canonical request representation.
pub fn canonical_artifact_manifest_hash(
    artifacts: &[ArtifactManifestEntry],
) -> Result<Option<String>, crate::error::WyrdError> {
    if artifacts.is_empty() {
        return Ok(None);
    }

    let mut canonical = artifacts.to_vec();
    canonical.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
    let bytes = serde_jcs::to_vec(&canonical).map_err(|error| {
        crate::error::WyrdError::from_spec_canonicalization(
            crate::envelope::SpecCanonicalizationError::Serialize(error),
        )
    })?;
    Ok(Some(blake3::hash(&bytes).to_hex().to_string()))
}

/// Refuse an artifact manifest that repeats a path or nests one path under
/// another.
///
/// A publication cannot hold both `a` and `a/b`: the first is a file while
/// the second needs it to be a directory. Registration enforces this at the
/// server; the loader runs the same check for an earlier, source-aware
/// diagnostic. Paths use `/` as their canonical separator, so each path's
/// ancestors are its prefixes ending before a `/`, compared without
/// platform path semantics.
///
/// # Errors
///
/// Returns [`crate::error::WyrdError::RegistryInvalidArtifactPath`] naming the
/// first repeated or nested path.
pub fn validate_artifact_manifest(
    artifacts: &[ArtifactManifestEntry],
) -> Result<(), crate::error::WyrdError> {
    let conflict = |path: &str, with: &str| crate::error::WyrdError::RegistryInvalidArtifactPath {
        message: format!("artifact path {path} conflicts with {with}"),
        details: serde_json::json!({ "relative_path": path, "conflicts_with": with }),
    };
    let mut paths = std::collections::HashSet::with_capacity(artifacts.len());
    for artifact in artifacts {
        let path = artifact.relative_path.as_str();
        if !paths.insert(path) {
            return Err(conflict(path, path));
        }
    }
    for path in &paths {
        if let Some((end, _)) = path
            .match_indices('/')
            .find(|(end, _)| paths.contains(&path[..*end]))
        {
            return Err(conflict(path, &path[..end]));
        }
    }
    Ok(())
}

/// One card submitted for registration.
///
/// The server derives relationships, lifecycle status, resolved version, and
/// UID. A submission is therefore intentionally smaller than a stored Card
/// envelope and cannot carry those server-managed fields.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardSubmission {
    /// API version.
    #[serde(rename = "apiVersion")]
    pub api_version: ApiVersion,
    /// Card kind.
    pub kind: CardKind,
    /// Author-supplied card metadata.
    pub metadata: Metadata,
    /// Kind-specific spec body.
    #[schemars(with = "serde_json::Value")]
    #[cfg_attr(feature = "server", schema(value_type = serde_json::Value))]
    pub spec: serde_json::Value,
    /// Heavy artifact manifest for this submission.
    #[serde(default)]
    pub artifacts: Vec<ArtifactManifestEntry>,
}

/// One heavy artifact declared by a submission.
///
/// An author may omit `sha256` and `size_bytes`; the client computes both
/// from the local file before any declaration call, so a request sent to the
/// server carries both and the server refuses an entry missing either.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ArtifactManifestEntry {
    /// Validated path below the declared card's artifact prefix.
    pub relative_path: crate::registry::RelativeArtifactPath,
    /// Base64-encoded SHA-256 digest of the artifact bytes; computed by the
    /// client when authored without one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha256: Option<String>,
    /// Artifact size in bytes; computed by the client when authored without
    /// one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub size_bytes: Option<u64>,
    /// Optional MIME type for the stored object.
    pub content_type: Option<String>,
}

/// Composite declaration request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclareCardRequest {
    /// Flat list of cards. The server topo-sorts the graph and derives root.
    pub submissions: Vec<CardSubmission>,
}

/// Outcome for one submission in a composite declaration.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardDeclarationOutcome {
    /// Server-resolved card identity.
    pub card_ref: CardRef,
    /// BLAKE3 hex hash of the JCS-canonical resolved spec.
    pub spec_hash: String,
    /// BLAKE3 hex hash of the sorted artifact manifest, when present.
    #[serde(default)]
    pub artifact_hash: Option<String>,
    /// Server-managed lifecycle state.
    pub status: CardLifecycleStatus,
    /// Declaration result for this submission.
    pub outcome: DeclarationOutcomeKind,
    /// URI of the durable card blob once written.
    #[schemars(with = "Option<String>")]
    #[cfg_attr(feature = "server", schema(value_type = Option<String>))]
    #[serde(default)]
    pub card_blob_uri: Option<Url>,
}

/// Upload plan group keyed by the resolved card reference.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardUploadPlan {
    /// Card whose submission declared the artifact manifest.
    pub card_ref: CardRef,
    /// One server-minted upload per manifest entry.
    pub entries: Vec<CardUploadEntry>,
}

/// Composite declaration response.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclareCardResponse {
    /// Server-derived graph root.
    pub root: CardRef,
    /// Per-submission outcomes in server topo order.
    pub outcomes: Vec<CardDeclarationOutcome>,
    /// Upload plans for artifact-bearing submissions.
    pub upload_plans: Vec<CardUploadPlan>,
}

/// Public SDK receipt after the engine completes all required side effects.
///
/// Upload plans and operation identifiers remain internal to the declaration
/// engine and are intentionally absent from this projection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclarationReceipt {
    /// Server-derived graph root.
    pub root: CardRef,
    /// Dependency-first server outcomes with final lifecycle state.
    pub outcomes: Vec<CardDeclarationOutcome>,
}

impl From<DeclareCardResponse> for DeclarationReceipt {
    fn from(response: DeclareCardResponse) -> Self {
        Self {
            root: response.root,
            outcomes: response.outcomes,
        }
    }
}

/// Durable declaration replay data used to mint upload URLs on demand.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclarationReplaySeed {
    /// Server-derived graph root.
    pub root: CardRef,
    /// Stable per-card outcomes.
    pub outcomes: Vec<CardDeclarationOutcome>,
    /// Artifact paths grouped by their resolved card reference.
    pub artifact_manifest_paths: Vec<(CardRef, Vec<RelativeArtifactPath>)>,
}

#[cfg(test)]
mod tests {
    use super::{
        ArtifactManifestEntry, CardSubmission, DeclarationReceipt, DeclareCardRequest,
        DeclareCardResponse, canonical_artifact_manifest_hash, validate_artifact_manifest,
    };
    use crate::api_version::ApiVersion;
    use crate::envelope::{CardKind, Metadata};
    use crate::registry::{CardLifecycleStatus, DeclarationOutcomeKind, RelativeArtifactPath};
    use serde_json::json;
    use std::collections::BTreeMap;

    /// Build a stable artifact entry for canonical-manifest tests.
    fn artifact(path: &str) -> ArtifactManifestEntry {
        ArtifactManifestEntry {
            relative_path: RelativeArtifactPath::new(path).expect("test artifact path is valid"),
            sha256: Some("YWJj".to_owned()),
            size_bytes: Some(3),
            content_type: None,
        }
    }

    /// Hash equivalent manifests identically when callers provide opposite file order.
    #[test]
    fn canonical_artifact_manifest_hash_ignores_authored_order() {
        let first = vec![artifact("weights.bin"), artifact("config.json")];
        let second = vec![artifact("config.json"), artifact("weights.bin")];

        assert_eq!(
            canonical_artifact_manifest_hash(&first).expect("first manifest hashes"),
            canonical_artifact_manifest_hash(&second).expect("second manifest hashes"),
        );
    }

    /// Refuse repeated and nested artifact paths while a sibling that only
    /// shares a name prefix, such as `weights-v2` beside `weights/a.bin`,
    /// is accepted.
    ///
    /// # Panics
    /// Panics when a conflicting manifest is accepted or a valid one refused.
    #[test]
    fn validate_artifact_manifest_refuses_repeated_and_nested_paths() {
        for paths in [
            &["a.txt", "a.txt"][..],
            &["weights", "weights-v2", "weights/a.bin"],
        ] {
            let manifest: Vec<_> = paths.iter().map(|path| artifact(path)).collect();
            let refused =
                validate_artifact_manifest(&manifest).expect_err("the manifest conflicts");
            assert_eq!(refused.code(), "WYRD_REGISTRY_400_INVALID_ARTIFACT_PATH");
        }
        let valid = [
            artifact("weights-v2"),
            artifact("weights/a.bin"),
            artifact("weights/b/c.bin"),
        ];
        validate_artifact_manifest(&valid).expect("distinct paths are accepted");
    }

    fn submission() -> CardSubmission {
        CardSubmission {
            api_version: ApiVersion::v1(),
            kind: CardKind::Audit,
            metadata: Metadata {
                name: "audit-a".parse().expect("test name is valid"),
                version: None,
                bump: None,
                space: Some("default".parse().expect("test space is valid")),
                uid: None,
                labels: BTreeMap::default(),
                annotations: BTreeMap::default(),
                spec_hash: None,
                artifact_hash: None,
                origin: None,
            },
            spec: json!({}),
            artifacts: Vec::new(),
        }
    }

    #[test]
    fn create_card_request_is_flat_submissions_list() {
        let request: DeclareCardRequest = serde_json::from_value(json!({
            "submissions": [{
                "apiVersion": "wyrd/v1",
                "kind": "Audit",
                "metadata": {"name": "audit-a", "space": "default"},
                "spec": {}
            }]
        }))
        .expect("flat request deserializes");
        assert_eq!(request.submissions.len(), 1);
        assert!(
            serde_json::from_value::<DeclareCardRequest>(json!({
                "card": {}, "artifacts": []
            }))
            .is_err()
        );
    }

    #[test]
    fn card_submission_artifacts_default_is_empty() {
        let encoded = serde_json::to_value(submission()).expect("submission serializes");
        assert!(encoded["artifacts"].as_array().is_some());
        assert!(encoded["artifacts"].as_array().expect("array").is_empty());
    }

    #[test]
    fn card_submission_server_managed_fields_are_not_top_level_fields() {
        let mut encoded = serde_json::to_value(submission()).expect("submission serializes");
        encoded["relationships"] = json!([]);
        encoded["status"] = json!("active");
        assert!(serde_json::from_value::<CardSubmission>(encoded).is_err());
    }

    #[test]
    fn create_card_response_carries_root_outcomes_and_upload_plans() {
        let response = DeclareCardResponse {
            root: serde_json::from_value(json!({
                "kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
            }))
            .expect("card ref deserializes"),
            outcomes: Vec::new(),
            upload_plans: Vec::new(),
        };
        assert!(response.outcomes.is_empty());
        assert!(response.upload_plans.is_empty());
        let _ = (
            CardLifecycleStatus::Pending,
            DeclarationOutcomeKind::Declared,
        );
    }

    #[test]
    fn registration_receipt_excludes_internal_upload_plans() {
        let response = DeclareCardResponse {
            root: serde_json::from_value(json!({
                "kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
            }))
            .expect("card ref deserializes"),
            outcomes: Vec::new(),
            upload_plans: Vec::new(),
        };
        let receipt = DeclarationReceipt::from(response);
        let value = serde_json::to_value(receipt).expect("receipt serializes");
        assert!(value.get("upload_plans").is_none());
        assert!(value.get("operation_id").is_none());
    }
}