use crate::core::store::Store;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(clap::Args, Debug)]
pub struct InternalizeCli {
#[clap(subcommand)]
pub command: InternalizeCommand,
}
#[derive(Subcommand, Debug)]
pub enum InternalizeCommand {
Create {
#[clap(long)]
source: String,
#[clap(long)]
model: String,
#[clap(long, default_value = "noop")]
profile: String,
#[clap(long, default_value_t = 0)]
ttl: u64,
#[clap(long = "scope", value_delimiter = ',')]
scopes: Vec<String>,
#[clap(long, default_value = "json")]
format: String,
},
Attach {
#[clap(long)]
id: String,
#[clap(long)]
session: String,
#[clap(long, default_value = "decapod-cli")]
tool: String,
#[clap(long, default_value_t = 1800)]
lease_seconds: u64,
#[clap(long, default_value = "json")]
format: String,
},
Detach {
#[clap(long)]
id: String,
#[clap(long)]
session: String,
#[clap(long, default_value = "json")]
format: String,
},
Inspect {
#[clap(long)]
id: String,
#[clap(long, default_value = "json")]
format: String,
},
}
pub const SCHEMA_VERSION: &str = "1.2.0";
pub const DEFAULT_ATTACH_LEASE_SECONDS: u64 = 1800;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DeterminismClass {
Deterministic,
BestEffort,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReplayClass {
Replayable,
NonReplayable,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InternalizationManifest {
pub schema_version: String,
pub id: String,
pub source_hash: String,
pub source_path: String,
pub extraction_method: String,
pub chunking_params: BTreeMap<String, serde_json::Value>,
pub base_model_id: String,
pub internalizer_profile: String,
pub internalizer_version: String,
pub adapter_format: String,
pub created_at: String,
pub ttl_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub provenance: Vec<ProvenanceEntry>,
pub replay_recipe: ReplayRecipe,
pub adapter_hash: String,
pub adapter_path: String,
pub capabilities_contract: CapabilitiesContract,
pub risk_tier: RiskTier,
pub determinism_class: DeterminismClass,
pub binary_hash: String,
pub runtime_fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProvenanceEntry {
pub op: String,
pub timestamp: String,
pub actor: String,
pub inputs_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReplayRecipe {
pub mode: ReplayClass,
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CapabilitiesContract {
pub allowed_scopes: Vec<String>,
pub permitted_tools: Vec<String>,
pub allow_code_gen: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RiskTier {
pub creation: String,
pub attach: String,
pub inspect: String,
}
impl Default for RiskTier {
fn default() -> Self {
Self {
creation: "compute-risky".to_string(),
attach: "behavior-changing".to_string(),
inspect: "read-only".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalizationCreateResult {
pub schema_version: String,
pub success: bool,
pub artifact_id: String,
pub artifact_path: String,
pub cache_hit: bool,
pub manifest: InternalizationManifest,
pub source_hash: String,
pub adapter_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalizationAttachResult {
pub schema_version: String,
pub success: bool,
pub artifact_id: String,
pub session_id: String,
pub tool: String,
pub attached_at: String,
pub lease_id: String,
pub lease_seconds: u64,
pub lease_expires_at: String,
pub expires_at: Option<String>,
pub capabilities_contract: CapabilitiesContract,
pub risk_classification: String,
pub source_verification: String,
pub provenance_entry: ProvenanceEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalizationDetachResult {
pub schema_version: String,
pub success: bool,
pub artifact_id: String,
pub session_id: String,
pub detached_at: String,
pub lease_id: String,
pub detached: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalizationInspectResult {
pub schema_version: String,
pub artifact_id: String,
pub manifest: InternalizationManifest,
pub integrity: IntegrityCheck,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityCheck {
pub source_hash_valid: bool,
pub source_verification: String,
pub adapter_hash_valid: bool,
pub manifest_consistent: bool,
pub expired: bool,
pub replayable_claim_valid: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalizerProfile {
pub name: String,
pub version: String,
pub executable: String,
pub default_params: BTreeMap<String, serde_json::Value>,
pub adapter_format: String,
pub determinism_class: DeterminismClass,
}
impl InternalizerProfile {
pub fn noop() -> Self {
Self {
name: "noop".to_string(),
version: "1.0.0".to_string(),
executable: "builtin:noop".to_string(),
default_params: BTreeMap::new(),
adapter_format: "noop".to_string(),
determinism_class: DeterminismClass::Deterministic,
}
}
pub fn resolve(name: &str, store_root: &Path) -> Result<Self, InternalizeError> {
if name == "noop" {
return Ok(Self::noop());
}
let profile_path = control_root(store_root)
.join("generated")
.join("profiles")
.join("internalizers")
.join(format!("{name}.json"));
if !profile_path.exists() {
return Err(InternalizeError::ProfileNotFound(name.to_string()));
}
let raw = fs::read_to_string(&profile_path).map_err(InternalizeError::Io)?;
serde_json::from_str(&raw).map_err(InternalizeError::Json)
}
pub fn binary_hash(&self) -> Result<String, InternalizeError> {
if self.executable == "builtin:noop" {
return sha256_bytes(self.executable.as_bytes());
}
let path = Path::new(&self.executable);
if !path.exists() {
return Err(InternalizeError::ProfileExecution(format!(
"Internalizer binary not found: {}",
self.executable
)));
}
sha256_file(path)
}
pub fn runtime_fingerprint(&self) -> String {
format!(
"os={} arch={} executable={}",
std::env::consts::OS,
std::env::consts::ARCH,
self.executable
)
}
pub fn execute(
&self,
source_path: &Path,
base_model: &str,
output_dir: &Path,
) -> Result<(PathBuf, BTreeMap<String, serde_json::Value>), InternalizeError> {
let adapter_file = output_dir.join("adapter.bin");
if self.executable == "builtin:noop" {
fs::write(&adapter_file, b"").map_err(InternalizeError::Io)?;
return Ok((adapter_file, self.default_params.clone()));
}
let input = serde_json::json!({
"source_path": source_path.to_string_lossy(),
"base_model": base_model,
"output_dir": output_dir.to_string_lossy(),
"params": self.default_params,
});
let output = ProcessCommand::new(&self.executable)
.arg("--input")
.arg(serde_json::to_string(&input).unwrap_or_default())
.output()
.map_err(InternalizeError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InternalizeError::ProfileExecution(format!(
"Internalizer '{}' failed: {}",
self.name, stderr
)));
}
if !adapter_file.exists() {
return Err(InternalizeError::ProfileExecution(format!(
"Internalizer '{}' did not produce adapter at {}",
self.name,
adapter_file.display()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let params = serde_json::from_str(&stdout).unwrap_or_else(|_| self.default_params.clone());
Ok((adapter_file, params))
}
}
#[derive(Debug)]
pub enum InternalizeError {
Io(std::io::Error),
Json(serde_json::Error),
ProfileNotFound(String),
ProfileExecution(String),
ArtifactNotFound(String),
MountNotFound {
artifact_id: String,
session_id: String,
},
SourceIntegrityFailed {
expected: String,
actual: String,
},
AdapterIntegrityFailed {
expected: String,
actual: String,
},
Expired {
artifact_id: String,
expired_at: String,
},
ToolNotPermitted {
tool: String,
artifact_id: String,
},
ValidationError(String),
}
impl std::fmt::Display for InternalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "IO error: {e}"),
Self::Json(e) => write!(f, "JSON error: {e}"),
Self::ProfileNotFound(n) => write!(f, "Internalizer profile '{n}' not found"),
Self::ProfileExecution(s) => write!(f, "Profile execution error: {s}"),
Self::ArtifactNotFound(id) => write!(f, "Artifact '{id}' not found"),
Self::MountNotFound {
artifact_id,
session_id,
} => write!(
f,
"No active mount for artifact '{artifact_id}' in session '{session_id}'"
),
Self::SourceIntegrityFailed { expected, actual } => write!(
f,
"Source integrity check failed: expected {expected}, got {actual}"
),
Self::AdapterIntegrityFailed { expected, actual } => write!(
f,
"Adapter integrity check failed: expected {expected}, got {actual}"
),
Self::Expired {
artifact_id,
expired_at,
} => write!(
f,
"Artifact '{artifact_id}' expired at {expired_at}; renew with a new create"
),
Self::ToolNotPermitted { tool, artifact_id } => write!(
f,
"Tool '{tool}' is not permitted to mount artifact '{artifact_id}'"
),
Self::ValidationError(s) => write!(f, "Validation error: {s}"),
}
}
}
impl std::error::Error for InternalizeError {}
impl From<InternalizeError> for crate::core::error::DecapodError {
fn from(e: InternalizeError) -> Self {
crate::core::error::DecapodError::ValidationError(e.to_string())
}
}
fn sha256_file(path: &Path) -> Result<String, InternalizeError> {
let bytes = fs::read(path).map_err(InternalizeError::Io)?;
sha256_bytes(&bytes)
}
fn sha256_bytes(bytes: &[u8]) -> Result<String, InternalizeError> {
let mut hasher = Sha256::new();
hasher.update(bytes);
Ok(format!("{:x}", hasher.finalize()))
}
fn iso8601_from_epoch(secs: u64) -> String {
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut year = 1970i64;
let mut remaining_days = days as i64;
loop {
let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
366
} else {
365
};
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let month_days = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 0usize;
for (i, &md) in month_days.iter().enumerate() {
if remaining_days < md as i64 {
month = i;
break;
}
remaining_days -= md as i64;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year,
month + 1,
remaining_days + 1,
hours,
minutes,
seconds
)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn now_iso8601() -> String {
iso8601_from_epoch(now_unix())
}
fn iso8601_after_secs(secs: u64) -> String {
iso8601_from_epoch(now_unix().saturating_add(secs))
}
fn control_root(store_root: &Path) -> PathBuf {
if store_root.file_name().and_then(|s| s.to_str()) == Some("data")
&& store_root
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
== Some(".decapod")
{
store_root
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| store_root.to_path_buf())
} else {
store_root.to_path_buf()
}
}
fn artifacts_dir(store_root: &Path) -> PathBuf {
control_root(store_root)
.join("generated")
.join("artifacts")
.join("internalizations")
}
fn artifact_dir(store_root: &Path, id: &str) -> PathBuf {
artifacts_dir(store_root).join(id)
}
fn session_dir(store_root: &Path, session_id: &str) -> PathBuf {
control_root(store_root)
.join("generated")
.join("sessions")
.join(session_id)
}
fn mount_dir(store_root: &Path, session_id: &str) -> PathBuf {
session_dir(store_root, session_id).join("internalize_mounts")
}
fn mount_id(artifact_id: &str) -> String {
format!("mount_{artifact_id}")
}
fn mount_path(store_root: &Path, session_id: &str, artifact_id: &str) -> PathBuf {
mount_dir(store_root, session_id).join(format!("{}.json", mount_id(artifact_id)))
}
fn is_non_local_source(source: &str) -> bool {
source == "-" || source.starts_with("http://") || source.starts_with("https://")
}
fn is_expired(expires_at: Option<&str>) -> bool {
expires_at.is_some_and(|exp| now_iso8601().as_str() > exp)
}
fn verify_source_binding(
manifest: &InternalizationManifest,
) -> Result<(bool, String), InternalizeError> {
if manifest.source_path == "-" {
return Ok((false, "best-effort-stdin-source".to_string()));
}
if manifest.source_path.starts_with("http://") || manifest.source_path.starts_with("https://") {
return Ok((false, "best-effort-nonlocal-source".to_string()));
}
let source_path = Path::new(&manifest.source_path);
if !source_path.exists() {
return Ok((false, "best-effort-source-unavailable".to_string()));
}
let actual = sha256_file(source_path)?;
if actual == manifest.source_hash {
Ok((true, "verified".to_string()))
} else {
Ok((false, "mismatch".to_string()))
}
}
fn tool_is_permitted(contract: &CapabilitiesContract, tool: &str) -> bool {
contract
.permitted_tools
.iter()
.any(|entry| entry == "*" || entry == tool)
}
fn artifact_id_for_request(
source_hash: &str,
source_path: &str,
model: &str,
profile: &InternalizerProfile,
ttl: u64,
scopes: &[String],
) -> Result<String, InternalizeError> {
let mut normalized_scopes = scopes.to_vec();
normalized_scopes.sort();
normalized_scopes.dedup();
let binding = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"source_hash": source_hash,
"source_path": source_path,
"base_model_id": model,
"internalizer_profile": profile.name,
"internalizer_version": profile.version,
"adapter_format": profile.adapter_format,
"determinism_class": profile.determinism_class,
"ttl_seconds": ttl,
"scopes": normalized_scopes,
"chunking_params": profile.default_params,
});
let bytes = serde_json::to_vec(&binding).map_err(InternalizeError::Json)?;
let hex = sha256_bytes(&bytes)?;
Ok(format!("int_{}", &hex[..24]))
}
fn build_replay_recipe(
profile: &InternalizerProfile,
binary_hash: &str,
source_path: &str,
model: &str,
ttl: u64,
scopes: &[String],
) -> ReplayRecipe {
let mut replay_args = vec![
"internalize".to_string(),
"create".to_string(),
"--source".to_string(),
source_path.to_string(),
"--model".to_string(),
model.to_string(),
"--profile".to_string(),
profile.name.clone(),
];
if ttl > 0 {
replay_args.push("--ttl".to_string());
replay_args.push(ttl.to_string());
}
for scope in scopes {
replay_args.push("--scope".to_string());
replay_args.push(scope.clone());
}
let (mode, reason) = match profile.determinism_class {
DeterminismClass::Deterministic if !binary_hash.is_empty() => (
ReplayClass::Replayable,
"deterministic profile with pinned binary hash".to_string(),
),
DeterminismClass::Deterministic => (
ReplayClass::NonReplayable,
"deterministic profile missing pinned binary hash".to_string(),
),
DeterminismClass::BestEffort => (
ReplayClass::NonReplayable,
"best_effort profile may depend on nondeterministic runtime or hardware".to_string(),
),
};
ReplayRecipe {
mode,
command: "decapod".to_string(),
args: replay_args,
env: BTreeMap::new(),
reason,
}
}
fn replayable_claim_valid(manifest: &InternalizationManifest) -> bool {
match manifest.replay_recipe.mode {
ReplayClass::Replayable => {
manifest.determinism_class == DeterminismClass::Deterministic
&& !manifest.binary_hash.trim().is_empty()
}
ReplayClass::NonReplayable => {
if manifest.determinism_class == DeterminismClass::BestEffort {
!manifest.binary_hash.trim().is_empty()
&& !manifest.runtime_fingerprint.trim().is_empty()
} else {
true
}
}
}
}
pub fn create_internalization(
store_root: &Path,
source: &str,
model: &str,
profile_name: &str,
ttl: u64,
scopes: &[String],
) -> Result<InternalizationCreateResult, InternalizeError> {
if is_non_local_source(source) {
return Err(InternalizeError::ValidationError(
"MVP only supports local file sources; URL and stdin sources are intentionally not implemented"
.to_string(),
));
}
let source_path = Path::new(source);
if !source_path.exists() {
return Err(InternalizeError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Source document not found: {source}"),
)));
}
let canonical_source = fs::canonicalize(source_path).map_err(InternalizeError::Io)?;
let source_hash = sha256_file(&canonical_source)?;
let profile = InternalizerProfile::resolve(profile_name, store_root)?;
let effective_scopes = if scopes.is_empty() {
vec!["qa".to_string()]
} else {
let mut normalized = scopes.to_vec();
normalized.sort();
normalized.dedup();
normalized
};
let allow_code_gen = effective_scopes.iter().any(|s| s == "code-gen");
let binary_hash = profile.binary_hash()?;
let runtime_fingerprint = profile.runtime_fingerprint();
let source_path_string = canonical_source.to_string_lossy().to_string();
let artifact_id = artifact_id_for_request(
&source_hash,
&source_path_string,
model,
&profile,
ttl,
&effective_scopes,
)?;
let art_dir = artifact_dir(store_root, &artifact_id);
let manifest_path = art_dir.join("manifest.json");
if manifest_path.exists() {
let raw = fs::read_to_string(&manifest_path).map_err(InternalizeError::Io)?;
let manifest: InternalizationManifest =
serde_json::from_str(&raw).map_err(InternalizeError::Json)?;
return Ok(InternalizationCreateResult {
schema_version: SCHEMA_VERSION.to_string(),
success: true,
artifact_id,
artifact_path: art_dir.to_string_lossy().to_string(),
cache_hit: true,
source_hash: manifest.source_hash.clone(),
adapter_hash: manifest.adapter_hash.clone(),
manifest,
});
}
fs::create_dir_all(&art_dir).map_err(InternalizeError::Io)?;
let (adapter_path, chunking_params) = profile.execute(&canonical_source, model, &art_dir)?;
let adapter_hash = sha256_file(&adapter_path)?;
let now = now_iso8601();
let expires_at = if ttl > 0 {
Some(iso8601_after_secs(ttl))
} else {
None
};
let replay_recipe = build_replay_recipe(
&profile,
&binary_hash,
&source_path_string,
model,
ttl,
&effective_scopes,
);
let provenance_entry = ProvenanceEntry {
op: "internalize.create".to_string(),
timestamp: now.clone(),
actor: "decapod-cli".to_string(),
inputs_hash: source_hash.clone(),
};
let manifest = InternalizationManifest {
schema_version: SCHEMA_VERSION.to_string(),
id: artifact_id.clone(),
source_hash: source_hash.clone(),
source_path: source_path_string,
extraction_method: profile.name.clone(),
chunking_params,
base_model_id: model.to_string(),
internalizer_profile: profile.name.clone(),
internalizer_version: profile.version.clone(),
adapter_format: profile.adapter_format.clone(),
created_at: now,
ttl_seconds: ttl,
expires_at,
provenance: vec![provenance_entry],
replay_recipe,
adapter_hash: adapter_hash.clone(),
adapter_path: "adapter.bin".to_string(),
capabilities_contract: CapabilitiesContract {
allowed_scopes: effective_scopes,
permitted_tools: vec!["decapod-cli".to_string()],
allow_code_gen,
},
risk_tier: RiskTier::default(),
determinism_class: profile.determinism_class,
binary_hash,
runtime_fingerprint,
};
let manifest_json = serde_json::to_string_pretty(&manifest).map_err(InternalizeError::Json)?;
fs::write(&manifest_path, manifest_json).map_err(InternalizeError::Io)?;
Ok(InternalizationCreateResult {
schema_version: SCHEMA_VERSION.to_string(),
success: true,
artifact_id,
artifact_path: art_dir.to_string_lossy().to_string(),
cache_hit: false,
manifest,
source_hash,
adapter_hash,
})
}
pub fn inspect_internalization(
store_root: &Path,
id: &str,
) -> Result<InternalizationInspectResult, InternalizeError> {
let art_dir = artifact_dir(store_root, id);
let manifest_path = art_dir.join("manifest.json");
if !manifest_path.exists() {
return Err(InternalizeError::ArtifactNotFound(id.to_string()));
}
let raw = fs::read_to_string(&manifest_path).map_err(InternalizeError::Io)?;
let manifest: InternalizationManifest =
serde_json::from_str(&raw).map_err(InternalizeError::Json)?;
let (source_hash_valid, source_verification) = verify_source_binding(&manifest)?;
let adapter_full_path = art_dir.join(&manifest.adapter_path);
let adapter_hash_valid = if adapter_full_path.exists() {
sha256_file(&adapter_full_path)? == manifest.adapter_hash
} else {
false
};
let expired = is_expired(manifest.expires_at.as_deref());
let replayable_claim_valid = replayable_claim_valid(&manifest);
let status = if expired {
"expired".to_string()
} else if !adapter_hash_valid || source_verification == "mismatch" || !replayable_claim_valid {
"integrity-failed".to_string()
} else if source_verification.starts_with("best-effort") {
"best-effort".to_string()
} else {
"valid".to_string()
};
Ok(InternalizationInspectResult {
schema_version: SCHEMA_VERSION.to_string(),
artifact_id: id.to_string(),
manifest,
integrity: IntegrityCheck {
source_hash_valid,
source_verification,
adapter_hash_valid,
manifest_consistent: true,
expired,
replayable_claim_valid,
},
status,
})
}
pub fn attach_internalization(
store_root: &Path,
id: &str,
session_id: &str,
tool: &str,
lease_seconds: u64,
) -> Result<InternalizationAttachResult, InternalizeError> {
let inspection = inspect_internalization(store_root, id)?;
if inspection.integrity.expired {
return Err(InternalizeError::Expired {
artifact_id: id.to_string(),
expired_at: inspection
.manifest
.expires_at
.clone()
.unwrap_or_else(|| "unknown".to_string()),
});
}
if inspection.integrity.source_verification == "mismatch" {
let actual = if Path::new(&inspection.manifest.source_path).exists() {
sha256_file(Path::new(&inspection.manifest.source_path))?
} else {
"unavailable".to_string()
};
return Err(InternalizeError::SourceIntegrityFailed {
expected: inspection.manifest.source_hash.clone(),
actual,
});
}
if !inspection.integrity.adapter_hash_valid {
return Err(InternalizeError::AdapterIntegrityFailed {
expected: inspection.manifest.adapter_hash.clone(),
actual: "corrupted".to_string(),
});
}
if !inspection.integrity.replayable_claim_valid {
return Err(InternalizeError::ValidationError(
"Artifact replayability metadata is inconsistent with determinism policy".to_string(),
));
}
if !tool_is_permitted(&inspection.manifest.capabilities_contract, tool) {
return Err(InternalizeError::ToolNotPermitted {
tool: tool.to_string(),
artifact_id: id.to_string(),
});
}
let attached_at = now_iso8601();
let lease_id = mount_id(id);
let lease_expires_at = iso8601_after_secs(lease_seconds);
let provenance_entry = ProvenanceEntry {
op: "internalize.attach".to_string(),
timestamp: attached_at.clone(),
actor: format!("session:{session_id}"),
inputs_hash: inspection.manifest.adapter_hash.clone(),
};
let mounts_dir = mount_dir(store_root, session_id);
fs::create_dir_all(&mounts_dir).map_err(InternalizeError::Io)?;
let mount = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"artifact_id": id,
"session_id": session_id,
"tool": tool,
"lease_id": lease_id,
"lease_seconds": lease_seconds,
"mounted_at": attached_at,
"lease_expires_at": lease_expires_at,
"adapter_hash": inspection.manifest.adapter_hash,
"source_verification": inspection.integrity.source_verification,
"capabilities_contract": inspection.manifest.capabilities_contract,
"risk_classification": inspection.manifest.risk_tier.attach
});
fs::write(
mount_path(store_root, session_id, id),
serde_json::to_string_pretty(&mount).map_err(InternalizeError::Json)?,
)
.map_err(InternalizeError::Io)?;
let session_prov_dir = session_dir(store_root, session_id);
fs::create_dir_all(&session_prov_dir).map_err(InternalizeError::Io)?;
let attach_log = session_prov_dir.join(format!("internalize_attach_{id}.json"));
let attach_entry = serde_json::json!({
"op": "internalize.attach",
"artifact_id": id,
"session_id": session_id,
"tool": tool,
"lease_id": lease_id,
"lease_seconds": lease_seconds,
"lease_expires_at": lease_expires_at,
"timestamp": attached_at,
"adapter_hash": inspection.manifest.adapter_hash,
"capabilities_contract": inspection.manifest.capabilities_contract,
"risk_classification": inspection.manifest.risk_tier.attach,
"source_verification": inspection.integrity.source_verification,
});
fs::write(
attach_log,
serde_json::to_string_pretty(&attach_entry).map_err(InternalizeError::Json)?,
)
.map_err(InternalizeError::Io)?;
Ok(InternalizationAttachResult {
schema_version: SCHEMA_VERSION.to_string(),
success: true,
artifact_id: id.to_string(),
session_id: session_id.to_string(),
tool: tool.to_string(),
attached_at,
lease_id,
lease_seconds,
lease_expires_at,
expires_at: inspection.manifest.expires_at,
capabilities_contract: inspection.manifest.capabilities_contract,
risk_classification: inspection.manifest.risk_tier.attach,
source_verification: inspection.integrity.source_verification,
provenance_entry,
})
}
pub fn detach_internalization(
store_root: &Path,
id: &str,
session_id: &str,
) -> Result<InternalizationDetachResult, InternalizeError> {
let mount_file = mount_path(store_root, session_id, id);
if !mount_file.exists() {
return Err(InternalizeError::MountNotFound {
artifact_id: id.to_string(),
session_id: session_id.to_string(),
});
}
let raw = fs::read_to_string(&mount_file).map_err(InternalizeError::Io)?;
let mount: serde_json::Value = serde_json::from_str(&raw).map_err(InternalizeError::Json)?;
let lease_id = mount
.get("lease_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
fs::remove_file(&mount_file).map_err(InternalizeError::Io)?;
let detached_at = now_iso8601();
let session_prov_dir = session_dir(store_root, session_id);
fs::create_dir_all(&session_prov_dir).map_err(InternalizeError::Io)?;
let detach_log = session_prov_dir.join(format!("internalize_detach_{id}.json"));
let detach_entry = serde_json::json!({
"op": "internalize.detach",
"artifact_id": id,
"session_id": session_id,
"lease_id": lease_id,
"timestamp": detached_at,
});
fs::write(
detach_log,
serde_json::to_string_pretty(&detach_entry).map_err(InternalizeError::Json)?,
)
.map_err(InternalizeError::Io)?;
Ok(InternalizationDetachResult {
schema_version: SCHEMA_VERSION.to_string(),
success: true,
artifact_id: id.to_string(),
session_id: session_id.to_string(),
detached_at,
lease_id,
detached: true,
})
}
pub fn manifest_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decapod.dev/schemas/internalization/manifest-1.2.0.json",
"title": "InternalizationManifest",
"type": "object",
"required": [
"schema_version", "id", "source_hash", "source_path", "base_model_id",
"internalizer_profile", "internalizer_version", "adapter_format", "created_at",
"ttl_seconds", "provenance", "replay_recipe", "adapter_hash", "adapter_path",
"capabilities_contract", "risk_tier", "determinism_class", "binary_hash",
"runtime_fingerprint"
],
"properties": {
"schema_version": { "const": SCHEMA_VERSION },
"id": { "type": "string", "pattern": "^int_[a-f0-9]{24}$" },
"source_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"determinism_class": { "enum": ["deterministic", "best_effort"] },
"binary_hash": { "type": "string", "minLength": 1 },
"runtime_fingerprint": { "type": "string", "minLength": 1 }
}
})
}
pub fn create_result_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decapod.dev/schemas/internalization/create-result-1.2.0.json",
"title": "InternalizationCreateResult",
"type": "object",
"required": [
"schema_version", "success", "artifact_id", "artifact_path",
"cache_hit", "manifest", "source_hash", "adapter_hash"
]
})
}
pub fn attach_result_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decapod.dev/schemas/internalization/attach-result-1.2.0.json",
"title": "InternalizationAttachResult",
"type": "object",
"required": [
"schema_version", "success", "artifact_id", "session_id", "tool",
"attached_at", "lease_id", "lease_seconds", "lease_expires_at"
]
})
}
pub fn detach_result_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decapod.dev/schemas/internalization/detach-result-1.2.0.json",
"title": "InternalizationDetachResult",
"type": "object",
"required": [
"schema_version", "success", "artifact_id", "session_id",
"detached_at", "lease_id", "detached"
]
})
}
pub fn inspect_result_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decapod.dev/schemas/internalization/inspect-result-1.2.0.json",
"title": "InternalizationInspectResult",
"type": "object",
"required": ["schema_version", "artifact_id", "manifest", "integrity", "status"]
})
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"name": "internalize",
"version": SCHEMA_VERSION,
"description": "Internalized context artifact lifecycle with explicit create, attach lease, detach, and inspect gates",
"commands": [
{ "name": "create", "parameters": ["source", "model", "profile", "ttl", "scope", "format"] },
{ "name": "attach", "parameters": ["id", "session", "tool", "lease_seconds", "format"] },
{ "name": "detach", "parameters": ["id", "session", "format"] },
{ "name": "inspect", "parameters": ["id", "format"] }
]
})
}
pub fn run_internalize_cli(
_store: &Store,
store_root: &Path,
cli: InternalizeCli,
) -> Result<(), crate::core::error::DecapodError> {
match cli.command {
InternalizeCommand::Create {
source,
model,
profile,
ttl,
scopes,
format,
} => {
let result =
create_internalization(store_root, &source, &model, &profile, ttl, &scopes)?;
if format == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
println!("Created internalization artifact: {}", result.artifact_id);
}
}
InternalizeCommand::Attach {
id,
session,
tool,
lease_seconds,
format,
} => {
let result = attach_internalization(store_root, &id, &session, &tool, lease_seconds)?;
if format == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
println!(
"Attached {} to session {} until {}",
result.artifact_id, result.session_id, result.lease_expires_at
);
}
}
InternalizeCommand::Detach {
id,
session,
format,
} => {
let result = detach_internalization(store_root, &id, &session)?;
if format == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
println!(
"Detached {} from session {}",
result.artifact_id, result.session_id
);
}
}
InternalizeCommand::Inspect { id, format } => {
let result = inspect_internalization(store_root, &id)?;
if format == "json" {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
println!("Artifact: {}", result.artifact_id);
println!(" Status: {}", result.status);
}
}
}
Ok(())
}