Skip to main content

lean_ctx/core/context_package/
manifest.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::graph_model::{GraphSummary, MarketplaceMeta};
5
6/// What a package delivers (unified distribution, GH #724). One taxonomy for
7/// everything an agent can install: knowledge (`context`), workflow content
8/// (`skills`), MCP servers (`addon`) and tree-sitter grammars (`grammar`).
9///
10/// Serialization is additive-only: the field is omitted for the default
11/// `context`, so every pre-existing v1/v2 package stays **byte-identical**
12/// and old readers (which tolerate unknown fields) keep working.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum PackageKind {
16    #[default]
17    Context,
18    Skills,
19    Addon,
20    Grammar,
21}
22
23impl PackageKind {
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            Self::Context => "context",
27            Self::Skills => "skills",
28            Self::Addon => "addon",
29            Self::Grammar => "grammar",
30        }
31    }
32
33    /// True for the default kind (used to omit the field when serializing).
34    pub(crate) fn is_context(&self) -> bool {
35        matches!(self, Self::Context)
36    }
37}
38
39/// Serialization is derived (`remote = "Self"` makes the derives inherent
40/// functions); [`Deserialize`] first folds the spec's dependency map forms into
41/// the list this engine writes, see `normalize_dependencies`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(remote = "Self")]
44pub struct PackageManifest {
45    pub schema_version: u32,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub conformance_level: Option<u32>,
48    /// What this package delivers (GH #724). Defaults to `context`; omitted
49    /// from serialized manifests for that default (byte-compat guarantee).
50    #[serde(default, skip_serializing_if = "PackageKind::is_context")]
51    pub kind: PackageKind,
52    pub name: String,
53    pub version: String,
54    pub description: String,
55    #[serde(default)]
56    pub author: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub scope: Option<String>,
59    pub created_at: DateTime<Utc>,
60    #[serde(default)]
61    pub updated_at: Option<DateTime<Utc>>,
62    pub layers: Vec<PackageLayer>,
63    #[serde(default)]
64    pub dependencies: Vec<PackageDependency>,
65    #[serde(default)]
66    pub tags: Vec<String>,
67    /// Registry visibility: `private` hides the package from catalog/search;
68    /// installs then need a namespace token (GL #524). `None` = public.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub visibility: Option<String>,
71    pub integrity: PackageIntegrity,
72    pub provenance: PackageProvenance,
73    #[serde(default)]
74    pub compatibility: CompatibilitySpec,
75    #[serde(default)]
76    pub stats: PackageStats,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub signature: Option<PackageSignature>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub graph_summary: Option<GraphSummary>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub marketplace: Option<MarketplaceMeta>,
83    /// V02: the author's grant that a recipient may pass the package on
84    /// (export, send, publish). Absent means not granted: a received package
85    /// without it stays with its recipient. Only a grant covered by the full
86    /// manifest signature counts, see [`super::rights`]. Omitted when absent,
87    /// so earlier packages stay byte-identical.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub redistributable: Option<bool>,
90}
91
92impl Serialize for PackageManifest {
93    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94        PackageManifest::serialize(self, serializer)
95    }
96}
97
98impl<'de> Deserialize<'de> for PackageManifest {
99    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100        use serde::de::Error;
101        let mut value = serde_json::Value::deserialize(deserializer)?;
102        normalize_dependencies(&mut value).map_err(D::Error::custom)?;
103        PackageManifest::deserialize(value).map_err(D::Error::custom)
104    }
105}
106
107/// Spec v2 §3.2 writes `dependencies` and `optional_dependencies` as maps of
108/// scoped name → SemVer range; this engine writes the list form. Both forms
109/// read into the one list, entries from `optional_dependencies` flagged
110/// optional, so neither form is silently dropped.
111fn normalize_dependencies(manifest: &mut serde_json::Value) -> Result<(), String> {
112    let Some(object) = manifest.as_object_mut() else {
113        return Ok(());
114    };
115    let mut entries = match object.remove("dependencies") {
116        None | Some(serde_json::Value::Null) => Vec::new(),
117        Some(value) => dependency_entries(value, false, "dependencies")?,
118    };
119    if let Some(value) = object
120        .remove("optional_dependencies")
121        .filter(|value| !value.is_null())
122    {
123        entries.extend(dependency_entries(value, true, "optional_dependencies")?);
124    }
125    object.insert("dependencies".into(), serde_json::Value::Array(entries));
126    Ok(())
127}
128
129fn dependency_entries(
130    value: serde_json::Value,
131    optional: bool,
132    member: &str,
133) -> Result<Vec<serde_json::Value>, String> {
134    match value {
135        serde_json::Value::Array(mut items) => {
136            if optional {
137                for item in items
138                    .iter_mut()
139                    .filter_map(serde_json::Value::as_object_mut)
140                {
141                    item.insert("optional".into(), serde_json::Value::Bool(true));
142                }
143            }
144            Ok(items)
145        }
146        serde_json::Value::Object(map) => map
147            .into_iter()
148            .map(|(name, range)| match range {
149                serde_json::Value::String(range) => Ok(serde_json::json!({
150                    "name": name,
151                    "version_req": range,
152                    "optional": optional,
153                })),
154                other => Err(format!(
155                    "{member}.{name} must be a SemVer range string, got {other}"
156                )),
157            })
158            .collect(),
159        other => Err(format!(
160            "{member} must be a list or a map of name to SemVer range, got {other}"
161        )),
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PackageSignature {
167    pub algorithm: String,
168    pub public_key: String,
169    pub value: String,
170    /// Signature over the whole manifest without this signature object
171    /// (`value` covers name, version and package digest only). Absent on
172    /// packages signed before it existed.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub manifest_value: Option<String>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum PackageLayer {
180    Knowledge,
181    Graph,
182    Session,
183    Patterns,
184    Gotchas,
185    Checkpoint,
186}
187
188impl PackageLayer {
189    pub fn as_str(&self) -> &'static str {
190        match self {
191            Self::Knowledge => "knowledge",
192            Self::Graph => "graph",
193            Self::Session => "session",
194            Self::Patterns => "patterns",
195            Self::Gotchas => "gotchas",
196            Self::Checkpoint => "checkpoint",
197        }
198    }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct PackageDependency {
203    pub name: String,
204    pub version_req: String,
205    #[serde(default)]
206    pub optional: bool,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PackageIntegrity {
211    pub sha256: String,
212    pub content_hash: String,
213    pub byte_size: u64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct PackageProvenance {
218    pub tool: String,
219    pub tool_version: String,
220    pub project_hash: Option<String>,
221    #[serde(default)]
222    pub source_session_id: Option<String>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, Default)]
226pub struct CompatibilitySpec {
227    #[serde(default)]
228    pub min_lean_ctx_version: Option<String>,
229    #[serde(default)]
230    pub target_languages: Vec<String>,
231    #[serde(default)]
232    pub target_frameworks: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, Default)]
236pub struct PackageStats {
237    pub knowledge_facts: u32,
238    pub graph_nodes: u32,
239    pub graph_edges: u32,
240    pub pattern_count: u32,
241    pub gotcha_count: u32,
242    pub compression_ratio: f64,
243}
244
245impl PackageManifest {
246    pub(crate) fn is_v2(&self) -> bool {
247        self.schema_version >= crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
248    }
249
250    pub(crate) fn validate(&self) -> Result<(), Vec<String>> {
251        let mut errors = Vec::new();
252
253        let v1 = crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION;
254        let v2 = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
255        if self.schema_version != v1 && self.schema_version != v2 {
256            errors.push(format!(
257                "unsupported schema_version {} (expected {v1} or {v2})",
258                self.schema_version,
259            ));
260        }
261        if self.name.is_empty() {
262            errors.push("name must not be empty".into());
263        }
264        if self.name.len() > 128 {
265            errors.push("name must be <= 128 characters".into());
266        }
267        if !self.name.chars().all(|c| {
268            c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@' || c == '/'
269        }) {
270            errors.push("name must only contain [a-zA-Z0-9._@/-]".into());
271        }
272        // K-P1-8: scopes are lowercase — the `@scope/` of a scoped name and
273        // the `scope` field alike. Uppercase is refused, never normalized.
274        let name_scope = self
275            .name
276            .strip_prefix('@')
277            .and_then(|scoped| scoped.split_once('/'))
278            .map(|(scope, _)| scope);
279        for scope in [name_scope, self.scope.as_deref()].into_iter().flatten() {
280            if scope.chars().any(char::is_uppercase) {
281                errors.push(
282                    "scope must be lowercase (uppercase scopes are refused, not normalized)".into(),
283                );
284            }
285        }
286        if self.version.is_empty() {
287            errors.push("version must not be empty".into());
288        }
289        if self.version.len() > 64 {
290            errors.push("version must be <= 64 characters".into());
291        }
292        if !self
293            .version
294            .chars()
295            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
296        {
297            errors.push("version must only contain [a-zA-Z0-9._+-]".into());
298        }
299        if self.version.starts_with('.') {
300            errors.push("version must not start with '.'".into());
301        }
302        if self.layers.is_empty() && !self.is_v2() {
303            errors.push("at least one layer is required".into());
304        }
305        // Unified distribution (GH #724): non-context kinds are a v2-era
306        // concept — a v1 package claiming to be an addon/skills/grammar pack
307        // is malformed, not merely old.
308        if !self.kind.is_context() && !self.is_v2() {
309            errors.push(format!(
310                "kind = {} requires schema_version {} (v2)",
311                self.kind.as_str(),
312                crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
313            ));
314        }
315        if self.layers.contains(&PackageLayer::Checkpoint)
316            && (!self.is_v2() || !self.kind.is_context())
317        {
318            errors.push("checkpoint layer requires schema_version 2 and kind=context".into());
319        }
320        let mut seen_layers = std::collections::HashSet::new();
321        for layer in &self.layers {
322            if !seen_layers.insert(layer.as_str()) {
323                errors.push(format!("duplicate layer: {}", layer.as_str()));
324            }
325        }
326        if !is_lower_hex(&self.integrity.sha256, 64) {
327            errors.push("integrity.sha256 must be a 64-char lowercase hex string".into());
328        }
329        if !is_lower_hex(&self.integrity.content_hash, 64) {
330            errors.push("integrity.content_hash must be a 64-char lowercase hex string".into());
331        }
332        if self.integrity.byte_size == 0 {
333            errors.push("integrity.byte_size must be > 0".into());
334        }
335
336        if errors.is_empty() {
337            Ok(())
338        } else {
339            Err(errors)
340        }
341    }
342
343    pub(crate) fn has_layer(&self, layer: PackageLayer) -> bool {
344        self.layers.contains(&layer)
345    }
346}
347
348/// Spec §1: digests, keys and signatures are lowercase hex.
349pub(crate) fn is_lower_hex(value: &str, len: usize) -> bool {
350    value.len() == len
351        && value
352            .bytes()
353            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn minimal_manifest() -> PackageManifest {
361        PackageManifest {
362            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
363            conformance_level: None,
364            kind: PackageKind::default(),
365            name: "test-pkg".into(),
366            version: "0.1.0".into(),
367            description: "A test package".into(),
368            author: None,
369            scope: None,
370            created_at: Utc::now(),
371            updated_at: None,
372            layers: vec![PackageLayer::Knowledge],
373            dependencies: vec![],
374            tags: vec![],
375            visibility: None,
376            integrity: PackageIntegrity {
377                sha256: "a".repeat(64),
378                content_hash: "b".repeat(64),
379                byte_size: 100,
380            },
381            provenance: PackageProvenance {
382                tool: "lean-ctx".into(),
383                tool_version: env!("CARGO_PKG_VERSION").into(),
384                project_hash: None,
385                source_session_id: None,
386            },
387            compatibility: CompatibilitySpec::default(),
388            stats: PackageStats::default(),
389            signature: None,
390            graph_summary: None,
391            marketplace: None,
392            redistributable: None,
393        }
394    }
395
396    #[test]
397    fn valid_manifest_passes() {
398        assert!(minimal_manifest().validate().is_ok());
399    }
400
401    #[test]
402    fn empty_name_fails() {
403        let mut m = minimal_manifest();
404        assert!(m.validate().is_ok());
405        m.name = String::new();
406        assert!(m.validate().is_err());
407    }
408
409    #[test]
410    fn duplicate_layers_fails() {
411        let mut m = minimal_manifest();
412        m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
413        assert!(m.validate().is_err());
414    }
415
416    #[test]
417    fn non_hex_sha256_fails() {
418        let mut m = minimal_manifest();
419        m.integrity.sha256 = "z".repeat(64);
420        assert!(m.validate().is_err());
421    }
422
423    #[test]
424    fn invalid_name_chars_fails() {
425        let mut m = minimal_manifest();
426        m.name = "my package!".into();
427        let errs = m.validate().unwrap_err();
428        assert!(errs.iter().any(|e| e.contains("only contain")));
429    }
430
431    #[test]
432    fn v2_schema_version_validates() {
433        let mut m = minimal_manifest();
434        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
435        assert!(m.validate().is_ok());
436    }
437
438    #[test]
439    fn scoped_name_validates() {
440        let mut m = minimal_manifest();
441        m.name = "@company/auth-service".into();
442        assert!(m.validate().is_ok());
443    }
444
445    #[test]
446    fn is_v2_flag() {
447        let mut m = minimal_manifest();
448        assert!(!m.is_v2());
449        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
450        assert!(m.is_v2());
451    }
452
453    #[test]
454    fn unsupported_schema_version_fails() {
455        let mut m = minimal_manifest();
456        m.schema_version = 99;
457        let errs = m.validate().unwrap_err();
458        assert!(
459            errs.iter()
460                .any(|e| e.contains("unsupported schema_version"))
461        );
462    }
463
464    #[test]
465    fn v2_manifest_serde_roundtrip() {
466        use super::super::graph_model::{GraphSummary, MarketplaceMeta};
467
468        let mut m = minimal_manifest();
469        m.schema_version = 2;
470        m.conformance_level = Some(2);
471        m.scope = Some("@company".into());
472        m.graph_summary = Some(GraphSummary {
473            node_count: 42,
474            edge_count: 100,
475            node_types: vec!["fact".into(), "gotcha".into()],
476            activation_mean: Some(0.75),
477            freshness: Some(Utc::now()),
478        });
479        m.marketplace = Some(MarketplaceMeta {
480            categories: vec!["security".into()],
481            badges: vec!["verified".into()],
482            license: Some("MIT".into()),
483        });
484
485        let json = serde_json::to_string(&m).unwrap();
486        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
487
488        assert_eq!(decoded.schema_version, 2);
489        assert_eq!(decoded.conformance_level, Some(2));
490        assert_eq!(decoded.scope.as_deref(), Some("@company"));
491        let gs = decoded.graph_summary.unwrap();
492        assert_eq!(gs.node_count, 42);
493        assert_eq!(gs.edge_count, 100);
494        let mp = decoded.marketplace.unwrap();
495        assert_eq!(mp.categories, vec!["security"]);
496        assert_eq!(mp.license.as_deref(), Some("MIT"));
497    }
498
499    #[test]
500    fn v1_manifest_missing_v2_fields_deserializes() {
501        let json = serde_json::to_string(&minimal_manifest()).unwrap();
502        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
503        assert!(decoded.conformance_level.is_none());
504        assert!(decoded.scope.is_none());
505        assert!(decoded.graph_summary.is_none());
506        assert!(decoded.marketplace.is_none());
507    }
508
509    /// Regression (AP13 K-P1-5): the spec writes dependencies as a map and
510    /// optional ones under `optional_dependencies`; the engine refused the map
511    /// and dropped the optional member. Both read into the list form it writes.
512    #[test]
513    fn spec_dependency_maps_read_into_the_written_list_form() {
514        let mut value = serde_json::to_value(minimal_manifest()).unwrap();
515        value["dependencies"] = serde_json::json!({"@acme/base": "^1.2"});
516        value["optional_dependencies"] = serde_json::json!({"@acme/extra": "~0.3"});
517        let manifest: PackageManifest = serde_json::from_value(value).unwrap();
518        let read: Vec<_> = manifest
519            .dependencies
520            .iter()
521            .map(|d| (d.name.as_str(), d.version_req.as_str(), d.optional))
522            .collect();
523        assert_eq!(
524            read,
525            [("@acme/base", "^1.2", false), ("@acme/extra", "~0.3", true)]
526        );
527        let written = serde_json::to_value(&manifest).unwrap();
528        assert!(written["dependencies"].is_array());
529        assert!(written.get("optional_dependencies").is_none());
530
531        let mut bad = serde_json::to_value(minimal_manifest()).unwrap();
532        bad["dependencies"] = serde_json::json!({"@acme/base": 1});
533        assert!(serde_json::from_value::<PackageManifest>(bad).is_err());
534    }
535
536    #[test]
537    fn nested_scope_validates() {
538        let mut m = minimal_manifest();
539        m.name = "@org/team/service".into();
540        assert!(m.validate().is_ok());
541    }
542
543    /// K-P1-8: an uppercase scope — in a scoped name or in the `scope`
544    /// field — is refused with one error and never normalized. Uppercase
545    /// outside the scope stays a valid name.
546    #[test]
547    fn uppercase_scopes_are_refused_not_normalized() {
548        let lowercase = "scope must be lowercase (uppercase scopes are refused, not normalized)";
549        for (name, scope) in [("@Acme/tool", None), ("@acme/tool", Some("@Acme"))] {
550            let mut m = minimal_manifest();
551            m.name = name.into();
552            m.scope = scope.map(Into::into);
553            assert_eq!(m.validate().unwrap_err(), vec![lowercase.to_string()]);
554            assert_eq!(m.name, name, "not normalized");
555        }
556        let mut m = minimal_manifest();
557        m.name = "@acme/Tool".into();
558        m.scope = Some("@acme".into());
559        assert!(m.validate().is_ok());
560    }
561
562    // ── kind taxonomy (GH #724, unified distribution) ──
563
564    /// Byte-compat guarantee: the default `context` kind is omitted when
565    /// serializing, so pre-#724 packages and fresh ones are byte-identical.
566    #[test]
567    fn default_kind_is_omitted_from_serialization() {
568        let json = serde_json::to_string(&minimal_manifest()).unwrap();
569        assert!(!json.contains("\"kind\""), "got: {json}");
570        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
571        assert_eq!(decoded.kind, PackageKind::Context);
572    }
573
574    #[test]
575    fn non_default_kind_round_trips() {
576        let mut m = minimal_manifest();
577        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
578        m.kind = PackageKind::Addon;
579        let json = serde_json::to_string(&m).unwrap();
580        assert!(json.contains("\"kind\":\"addon\""), "got: {json}");
581        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
582        assert_eq!(decoded.kind, PackageKind::Addon);
583        assert!(decoded.validate().is_ok());
584    }
585
586    /// A v1 manifest claiming a non-context kind is malformed, not merely old.
587    #[test]
588    fn non_context_kind_requires_v2_schema() {
589        let mut m = minimal_manifest();
590        m.kind = PackageKind::Skills;
591        let errs = m.validate().unwrap_err();
592        assert!(
593            errs.iter().any(|e| e.contains("requires schema_version")),
594            "got: {errs:?}"
595        );
596        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
597        assert!(m.validate().is_ok());
598    }
599
600    /// Old readers must not choke on a manifest that carries the new field —
601    /// and new readers must parse legacy manifests without it (serde default).
602    #[test]
603    fn kind_field_is_forward_and_backward_tolerant() {
604        let mut m = minimal_manifest();
605        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
606        m.kind = PackageKind::Grammar;
607        let with_kind = serde_json::to_string(&m).unwrap();
608        let decoded: PackageManifest = serde_json::from_str(&with_kind).unwrap();
609        assert_eq!(decoded.kind, PackageKind::Grammar);
610
611        let legacy = serde_json::to_string(&minimal_manifest()).unwrap();
612        let decoded: PackageManifest = serde_json::from_str(&legacy).unwrap();
613        assert_eq!(decoded.kind, PackageKind::Context);
614    }
615}