Skip to main content

lean_ctx/core/context_package/
content.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::core::knowledge::{ConsolidatedInsight, KnowledgeFact, ProjectPattern};
7
8use super::graph_model::ContextGraph;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct PackageContent {
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub knowledge: Option<KnowledgeLayer>,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub graph: Option<GraphLayer>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub session: Option<SessionLayer>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub patterns: Option<PatternsLayer>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub gotchas: Option<GotchasLayer>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub context_graph: Option<ContextGraph>,
24    /// `kind=addon` payload (GH #724/#726): the embedded addon manifest.
25    /// Absent for every other kind — enforced by
26    /// `super::verify::validate_kind_coherence`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub addon: Option<AddonContent>,
29    /// `kind=skills` payload (GH #724/#727): named, verified content blobs.
30    /// Absent for every other kind — enforced by
31    /// `super::verify::validate_kind_coherence`.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub documents: Option<DocumentsContent>,
34    /// Portable P6 checkpoint envelope. Presence is coherent IFF the manifest
35    /// declares the explicit `checkpoint` layer, making pre-extension readers
36    /// reject the package at their closed enum parse boundary.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub checkpoint: Option<CheckpointPackageContentV1>,
39}
40
41pub(crate) const CHECKPOINT_PACKAGE_SCHEMA_V1: &str = "leanctx.ctxpkg-checkpoint/v1";
42/// Carries the public live checkpoint, not the historical SDK envelope.
43pub(crate) const CHECKPOINT_PACKAGE_SCHEMA_V2: &str = "leanctx.ctxpkg-checkpoint/v2";
44/// Carries canonical V2 with artifact lineage; never demoted to live V1.
45pub const CHECKPOINT_PACKAGE_SCHEMA_V3: &str = "leanctx.ctxpkg-checkpoint/v3";
46/// Carries canonical Unicode V3; older carrier versions remain strict.
47pub const CHECKPOINT_PACKAGE_SCHEMA_V4: &str = "leanctx.ctxpkg-checkpoint/v4";
48pub(crate) const MAX_CHECKPOINT_PACKAGE_BYTES: usize = 8 * 1024 * 1024;
49pub(crate) const MAX_CHECKPOINT_SOURCES: usize = 128;
50pub(crate) const MAX_CHECKPOINT_ENTRIES: usize = 256;
51pub(crate) const MAX_CHECKPOINT_REFS: usize = 4096;
52pub(crate) const MAX_CHECKPOINT_PACKAGE_PINS: usize = 128;
53
54/// Versioned mechanism-level carrier. Schema v1 holds the historical SDK
55/// envelope; schemas v2/v3/v4 hold canonical live V1/V2/V3 respectively.
56/// Package integrity alone does not authorize session adoption.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct CheckpointPackageContentV1 {
60    pub schema_version: String,
61    pub checkpoint: serde_json::Value,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub migration_provenance: Option<serde_json::Value>,
64    pub non_portable_fields: Vec<String>,
65}
66
67/// Distribution view of an addon (unified distribution, GH #726): the
68/// authoring `lean-ctx-addon.toml` embedded **verbatim**. The TOML is the
69/// single source of truth — MCP wiring, capabilities, `[install]` bootstrap
70/// and the per-platform `[artifacts]` tables (GH #725) all live inside it,
71/// so nothing is duplicated at the pack layer that could drift.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct AddonContent {
74    /// Verbatim `lean-ctx-addon.toml` text (authoring contract
75    /// `docs/contracts/addon-manifest-v1.md`).
76    pub manifest_toml: String,
77    /// WASM modules carried **inside** the pack, hash-pinned exactly like
78    /// `kind=skills` documents.
79    ///
80    /// Embedding rather than referencing external artifacts is the whole point:
81    /// the pack's own signature covers the executable bytes, so an author needs
82    /// no artifact host, no checksum files and no CI to publish one — the
83    /// friction the previous addon channel was reported for. It also removes a
84    /// download step from install, and with it a class of TOCTOU between
85    /// "verified the manifest" and "fetched the binary".
86    ///
87    /// Empty on a pre-extension pack; `validate_kind_coherence` requires at
88    /// least one module for `kind=addon`, so an empty list can only appear on a
89    /// package that predates this field and is rejected there.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub modules: Vec<DocumentBlob>,
92}
93
94/// Caps for `kind=addon` module payloads. A WASM module is larger than a
95/// markdown file but still small: `wasmi` instantiates per call, so a
96/// multi-megabyte guest is a design smell, not a use case.
97pub(crate) const MAX_ADDON_MODULES: usize = 8;
98pub(crate) const MAX_ADDON_MODULE_BYTES: usize = 8 * 1024 * 1024;
99pub(crate) const MAX_ADDON_TOTAL_BYTES: usize = 16 * 1024 * 1024;
100
101/// `kind=skills` payload (GH #727): a set of named, verified content blobs
102/// (markdown/scripts). **No execution semantics in lean-ctx** — skills are
103/// verified *content*; interpretation belongs to the consumer (an addon like
104/// lean-md, or the agent itself).
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
106pub struct DocumentsContent {
107    /// Sorted by `path` (byte order) — deterministic pack bytes (#498).
108    pub files: Vec<DocumentBlob>,
109}
110
111/// Body encoding marker for [`DocumentBlob::body`]. The only supported value;
112/// a field (not an enum) so future encodings fail with "unsupported encoding"
113/// on old readers instead of a serde parse error.
114pub(crate) const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64";
115
116/// Per-file caps (plaintext bytes) — a skills pack is documentation and
117/// scripts, not a media archive.
118pub(crate) const MAX_DOCUMENT_FILES: usize = 256;
119pub(crate) const MAX_DOCUMENT_FILE_BYTES: usize = 1024 * 1024;
120pub(crate) const MAX_DOCUMENTS_TOTAL_BYTES: usize = 8 * 1024 * 1024;
121
122/// One named blob: `path` + SHA-256 of the **plaintext** + compressed body.
123/// The hash pins the decoded bytes, so tampering with the stored body (or a
124/// decompression bug) is detected before anything lands on disk.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct DocumentBlob {
127    /// Relative, `/`-separated path inside the pack (e.g. `skills/review.md`).
128    pub path: String,
129    /// SHA-256 (lowercase hex) of the plaintext bytes.
130    pub sha256: String,
131    /// Body encoding — currently always `DOCUMENT_ENCODING_ZSTD_B64`.
132    pub encoding: String,
133    /// base64(zstd(plaintext)).
134    pub body: String,
135}
136
137impl DocumentBlob {
138    /// Build a blob from plaintext bytes (deterministic: fixed zstd level).
139    pub fn from_plaintext(path: &str, bytes: &[u8]) -> Result<Self, String> {
140        let compressed =
141            zstd::encode_all(bytes, 3).map_err(|e| format!("zstd compress {path}: {e}"))?;
142        Ok(Self {
143            path: path.to_string(),
144            sha256: sha256_hex_of(bytes),
145            encoding: DOCUMENT_ENCODING_ZSTD_B64.to_string(),
146            body: base64_encode(&compressed),
147        })
148    }
149
150    /// Decode and verify the body against its `sha256` pin. Any mismatch —
151    /// tampered body, wrong hash, corrupt compression — is an error; callers
152    /// never see unverified bytes.
153    pub fn decode_verified(&self) -> Result<Vec<u8>, String> {
154        if self.encoding != DOCUMENT_ENCODING_ZSTD_B64 {
155            return Err(format!(
156                "`{}`: unsupported encoding `{}` (newer lean-ctx required)",
157                self.path, self.encoding
158            ));
159        }
160        let compressed = base64_decode(&self.body)
161            .map_err(|e| format!("`{}`: body is not valid base64: {e}", self.path))?;
162        // Cap the decompressed size before allocating: a hostile blob must
163        // not zstd-bomb the installer.
164        let plain = zstd::bulk::decompress(&compressed, MAX_DOCUMENT_FILE_BYTES + 1)
165            .map_err(|e| format!("`{}`: zstd decompress failed: {e}", self.path))?;
166        if plain.len() > MAX_DOCUMENT_FILE_BYTES {
167            return Err(format!(
168                "`{}`: decoded size exceeds the {} byte cap",
169                self.path, MAX_DOCUMENT_FILE_BYTES
170            ));
171        }
172        let actual = sha256_hex_of(&plain);
173        if !actual.eq_ignore_ascii_case(&self.sha256) {
174            return Err(format!(
175                "`{}`: content hash mismatch — expected {}, got {actual} (tampered blob)",
176                self.path, self.sha256
177            ));
178        }
179        Ok(plain)
180    }
181}
182
183fn sha256_hex_of(bytes: &[u8]) -> String {
184    use sha2::{Digest, Sha256};
185    let mut h = Sha256::new();
186    h.update(bytes);
187    crate::core::agent_identity::hex_encode(&h.finalize())
188}
189
190fn base64_encode(bytes: &[u8]) -> String {
191    use base64::Engine;
192    base64::engine::general_purpose::STANDARD.encode(bytes)
193}
194
195fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
196    use base64::Engine;
197    base64::engine::general_purpose::STANDARD
198        .decode(text.trim())
199        .map_err(|e| e.to_string())
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct KnowledgeLayer {
204    pub facts: Vec<KnowledgeFact>,
205    pub patterns: Vec<ProjectPattern>,
206    pub insights: Vec<ConsolidatedInsight>,
207    pub exported_at: DateTime<Utc>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct GraphLayer {
212    pub nodes: Vec<GraphNodeExport>,
213    pub edges: Vec<GraphEdgeExport>,
214    pub exported_at: DateTime<Utc>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct GraphNodeExport {
219    pub kind: String,
220    pub name: String,
221    pub file_path: String,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub line_start: Option<usize>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub line_end: Option<usize>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub metadata: Option<String>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GraphEdgeExport {
232    pub source_path: String,
233    pub source_name: String,
234    pub target_path: String,
235    pub target_name: String,
236    pub kind: String,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub metadata: Option<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct SessionLayer {
243    pub task_description: Option<String>,
244    pub findings: Vec<SessionFinding>,
245    pub decisions: Vec<SessionDecision>,
246    pub next_steps: Vec<String>,
247    pub files_touched: Vec<String>,
248    pub exported_at: DateTime<Utc>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct SessionFinding {
253    pub summary: String,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub file: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub line: Option<u32>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct SessionDecision {
262    pub summary: String,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub rationale: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct PatternsLayer {
269    pub patterns: Vec<ProjectPattern>,
270    pub exported_at: DateTime<Utc>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct GotchasLayer {
275    pub gotchas: Vec<GotchaExport>,
276    pub exported_at: DateTime<Utc>,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct GotchaExport {
281    pub id: String,
282    pub category: String,
283    pub severity: String,
284    pub trigger: String,
285    pub resolution: String,
286    #[serde(default)]
287    pub file_patterns: Vec<String>,
288    pub confidence: f32,
289}
290
291impl PackageContent {
292    pub(crate) fn active_layer_count(&self) -> usize {
293        let mut n = 0;
294        if self.knowledge.is_some() {
295            n += 1;
296        }
297        if self.graph.is_some() {
298            n += 1;
299        }
300        if self.session.is_some() {
301            n += 1;
302        }
303        if self.patterns.is_some() {
304            n += 1;
305        }
306        if self.gotchas.is_some() {
307            n += 1;
308        }
309        if self.context_graph.is_some() {
310            n += 1;
311        }
312        if self.addon.is_some() {
313            n += 1;
314        }
315        if self.documents.is_some() {
316            n += 1;
317        }
318        if self.checkpoint.is_some() {
319            n += 1;
320        }
321        n
322    }
323
324    pub(crate) fn is_empty(&self) -> bool {
325        self.active_layer_count() == 0
326    }
327
328    pub fn estimated_token_count(&self) -> usize {
329        let json = serde_json::to_string(self).unwrap_or_default();
330        json.len() / 4
331    }
332}