Skip to main content

harn_cli/package/
persona_activation.rs

1use std::collections::BTreeMap;
2use std::fs::{self, File, OpenOptions};
3use std::io;
4use std::path::{Path, PathBuf};
5
6use fs2::FileExt;
7use harn_modules::personas::{
8    PersonaAutonomyTier, PersonaBudget, PersonaManifestEntry, PersonaModelPolicy,
9    PersonaReceiptPolicy,
10};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use super::{load_root_persona_catalog, resolve_discoverable_persona_in_root, DiscoverablePersona};
15
16const ACTIVATION_SCHEMA_VERSION: u32 = 2;
17const LEGACY_ACTIVATION_SCHEMA_VERSION: u32 = 1;
18const ACTIVATION_RECEIPT_SCHEMA_VERSION: u32 = 2;
19const ACTIVATION_DIR: &str = ".harn/personas";
20const ACTIVATION_FILE: &str = "activations.json";
21const ACTIVATION_LOCK_FILE: &str = "activations.lock";
22
23#[derive(Debug, thiserror::Error)]
24pub enum PersonaActivationError {
25    #[error("{0}")]
26    Catalog(String),
27    #[error("persona '{0}' is a root persona and does not require activation")]
28    RootPersona(String),
29    #[error(
30        "installed persona '{0}' has no package content hash; run `harn install` before activation"
31    )]
32    MissingContentHash(String),
33    #[error("installed persona '{0}' has no pinned package-generation lock digest")]
34    MissingLockDigest(String),
35    #[error("installed persona '{persona_id}' failed package integrity validation: {integrity}")]
36    PackageIntegrity {
37        persona_id: String,
38        integrity: String,
39    },
40    #[error("activated persona '{persona_id}' is stale: {reason}; reactivate it before use")]
41    StaleActivation { persona_id: String, reason: String },
42    #[error("invalid persona attenuation: {0}")]
43    InvalidAttenuation(String),
44    #[error(
45        "activation ledger {path} uses unsupported schema version {actual}; expected {expected}"
46    )]
47    UnsupportedSchema {
48        path: String,
49        actual: u32,
50        expected: u32,
51    },
52    #[error("activation ledger {path} is invalid: {message}")]
53    InvalidLedger { path: String, message: String },
54    #[error("failed to {operation} activation state at {path}: {source}")]
55    Io {
56        operation: &'static str,
57        path: String,
58        #[source]
59        source: io::Error,
60    },
61    #[error("failed to serialize activation state: {0}")]
62    Serialize(#[from] serde_json::Error),
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct PersonaAttenuation {
67    pub autonomy_tier: Option<PersonaAutonomyTier>,
68    /// `None` inherits the exported set; `Some([])` denies the entire set.
69    pub tools: Option<Vec<String>>,
70    /// `None` inherits the exported set; `Some([])` denies the entire set.
71    pub capabilities: Option<Vec<String>>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct PersonaEffectivePolicy {
77    pub autonomy_tier: PersonaAutonomyTier,
78    pub tools: Vec<String>,
79    pub capabilities: Vec<String>,
80}
81
82#[derive(Serialize)]
83struct PersonaExportContract {
84    persona: PersonaManifestEntry,
85    permissions: Vec<String>,
86    host_requirements: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct PersonaActivationPackage {
92    pub alias: String,
93    pub version: Option<String>,
94    pub content_hash: String,
95    #[serde(default)]
96    pub lock_digest: String,
97    pub source: String,
98    pub manifest_path: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct PersonaActivationRecord {
104    pub persona_id: String,
105    pub package: PersonaActivationPackage,
106    pub exported_policy_digest: String,
107    pub effective_policy_digest: String,
108    pub effective_policy: PersonaEffectivePolicy,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub migration: Option<PersonaActivationMigration>,
111    pub activated_at_ms: i64,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct PersonaActivationMigration {
117    pub status: PersonaActivationMigrationStatus,
118    pub source_schema_version: u32,
119    pub legacy_effective_policy_digest: String,
120    /// Validated schema-v1 policy retained for audit only. None of these
121    /// archived values are treated as an active runtime grant.
122    pub not_enforced_policy: serde_json::Value,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PersonaActivationMigrationStatus {
128    ReactivationRequired,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct PersonaActivationLedger {
134    pub schema_version: u32,
135    pub activations: BTreeMap<String, PersonaActivationRecord>,
136}
137
138#[derive(Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140struct LegacyPersonaEffectivePolicyV1 {
141    autonomy_tier: PersonaAutonomyTier,
142    receipt_policy: PersonaReceiptPolicy,
143    tools: Vec<String>,
144    capabilities: Vec<String>,
145    permissions: Vec<String>,
146    host_requirements: Vec<String>,
147    model_policy: PersonaModelPolicy,
148    budget: PersonaBudget,
149}
150
151#[derive(Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153struct LegacyPersonaActivationRecordV1 {
154    persona_id: String,
155    package: PersonaActivationPackage,
156    exported_policy_digest: String,
157    effective_policy_digest: String,
158    effective_policy: LegacyPersonaEffectivePolicyV1,
159    activated_at_ms: i64,
160}
161
162#[derive(Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164struct LegacyPersonaActivationLedgerV1 {
165    schema_version: u32,
166    activations: BTreeMap<String, LegacyPersonaActivationRecordV1>,
167}
168
169#[derive(Deserialize)]
170struct ActivationLedgerVersion {
171    schema_version: u32,
172}
173
174impl Default for PersonaActivationLedger {
175    fn default() -> Self {
176        Self {
177            schema_version: ACTIVATION_SCHEMA_VERSION,
178            activations: BTreeMap::new(),
179        }
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum PersonaActivationAction {
186    Activate,
187    Deactivate,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct PersonaActivationReceipt {
193    pub schema_version: u32,
194    pub action: PersonaActivationAction,
195    pub persona_id: String,
196    pub changed: bool,
197    pub occurred_at_ms: i64,
198    pub ledger_path: String,
199    pub activation: Option<PersonaActivationRecord>,
200}
201
202pub fn activate_persona(
203    manifest: Option<&Path>,
204    persona_id: &str,
205    attenuation: &PersonaAttenuation,
206    now_ms: i64,
207) -> Result<PersonaActivationReceipt, PersonaActivationError> {
208    let root = load_root_persona_catalog(manifest).map_err(PersonaActivationError::Catalog)?;
209    let discovered = resolve_discoverable_persona_in_root(&root, persona_id)
210        .map_err(PersonaActivationError::Catalog)?;
211    let candidate = activation_record(&discovered, attenuation, now_ms)?;
212    let ledger_path = activation_ledger_path(&root.manifest_dir);
213    let candidate_id = candidate.persona_id.clone();
214    let (changed, activation) = mutate_activation_ledger(&root.manifest_dir, |ledger| {
215        if let Some(existing) = ledger.activations.get(&candidate_id) {
216            let mut comparable = candidate.clone();
217            comparable.activated_at_ms = existing.activated_at_ms;
218            if &comparable == existing {
219                return (false, Some(existing.clone()));
220            }
221        }
222        ledger
223            .activations
224            .insert(candidate_id.clone(), candidate.clone());
225        (true, Some(candidate))
226    })?;
227    Ok(PersonaActivationReceipt {
228        schema_version: ACTIVATION_RECEIPT_SCHEMA_VERSION,
229        action: PersonaActivationAction::Activate,
230        persona_id: candidate_id,
231        changed,
232        occurred_at_ms: now_ms,
233        ledger_path: ledger_path.display().to_string(),
234        activation,
235    })
236}
237
238pub fn deactivate_persona(
239    manifest: Option<&Path>,
240    persona_id: &str,
241    now_ms: i64,
242) -> Result<PersonaActivationReceipt, PersonaActivationError> {
243    let root = load_root_persona_catalog(manifest).map_err(PersonaActivationError::Catalog)?;
244    let ledger_path = activation_ledger_path(&root.manifest_dir);
245    let (changed, activation) = mutate_activation_ledger(&root.manifest_dir, |ledger| {
246        let removed = ledger.activations.remove(persona_id);
247        (removed.is_some(), removed)
248    })?;
249    Ok(PersonaActivationReceipt {
250        schema_version: ACTIVATION_RECEIPT_SCHEMA_VERSION,
251        action: PersonaActivationAction::Deactivate,
252        persona_id: persona_id.to_string(),
253        changed,
254        occurred_at_ms: now_ms,
255        ledger_path: ledger_path.display().to_string(),
256        activation,
257    })
258}
259
260pub fn list_persona_activations(
261    manifest: Option<&Path>,
262) -> Result<Vec<PersonaActivationRecord>, PersonaActivationError> {
263    let root = load_root_persona_catalog(manifest).map_err(PersonaActivationError::Catalog)?;
264    Ok(load_activation_ledger(&root.manifest_dir)?
265        .activations
266        .into_values()
267        .collect())
268}
269
270pub fn load_activation_ledger(
271    project_root: &Path,
272) -> Result<PersonaActivationLedger, PersonaActivationError> {
273    let path = activation_ledger_path(project_root);
274    let bytes = match fs::read(&path) {
275        Ok(bytes) => bytes,
276        Err(error) if error.kind() == io::ErrorKind::NotFound => {
277            return Ok(PersonaActivationLedger::default());
278        }
279        Err(source) => return Err(io_error("read", &path, source)),
280    };
281    let ledger = decode_activation_ledger(&path, &bytes)?;
282    validate_ledger(&path, &ledger)?;
283    Ok(ledger)
284}
285
286pub fn activation_ledger_path(project_root: &Path) -> PathBuf {
287    project_root.join(ACTIVATION_DIR).join(ACTIVATION_FILE)
288}
289
290pub(crate) fn materialize_activated_persona(
291    discovered: &DiscoverablePersona,
292    activation: &PersonaActivationRecord,
293) -> Result<PersonaManifestEntry, PersonaActivationError> {
294    if activation.migration.is_some() {
295        return Err(stale_activation(
296            activation,
297            "schema-v1 policy requires explicit reactivation".to_string(),
298        ));
299    }
300    let provenance = discovered
301        .installed_provenance()
302        .ok_or_else(|| PersonaActivationError::RootPersona(discovered.id.clone()))?;
303    if activation.persona_id != discovered.id {
304        return Err(stale_activation(
305            activation,
306            format!("resolved identity is '{}'", discovered.id),
307        ));
308    }
309    if !matches!(provenance.integrity.as_str(), "ok" | "observed") {
310        return Err(PersonaActivationError::PackageIntegrity {
311            persona_id: discovered.id.clone(),
312            integrity: provenance.integrity.clone(),
313        });
314    }
315    for (field, pinned, current) in [
316        (
317            "package alias",
318            activation.package.alias.as_str(),
319            provenance.package_alias.as_str(),
320        ),
321        (
322            "content hash",
323            activation.package.content_hash.as_str(),
324            provenance.content_hash.as_deref().unwrap_or(""),
325        ),
326        (
327            "package-generation lock digest",
328            activation.package.lock_digest.as_str(),
329            provenance.lock_digest.as_deref().unwrap_or(""),
330        ),
331        (
332            "package source",
333            activation.package.source.as_str(),
334            provenance.source.as_str(),
335        ),
336    ] {
337        if pinned != current {
338            return Err(stale_activation(
339                activation,
340                format!("pinned {field} '{pinned}' changed to '{current}'"),
341            ));
342        }
343    }
344    if activation.package.version != provenance.package_version {
345        return Err(stale_activation(
346            activation,
347            format!(
348                "pinned package version {:?} changed to {:?}",
349                activation.package.version, provenance.package_version
350            ),
351        ));
352    }
353
354    let exported = exported_policy_contract(&discovered.persona, provenance)?;
355    if policy_digest(&exported)? != activation.exported_policy_digest {
356        return Err(stale_activation(
357            activation,
358            "exported persona policy changed".to_string(),
359        ));
360    }
361    let effective = &activation.effective_policy;
362    let recomputed = effective_policy(
363        &discovered.persona,
364        &PersonaAttenuation {
365            autonomy_tier: Some(effective.autonomy_tier),
366            tools: Some(effective.tools.clone()),
367            capabilities: Some(effective.capabilities.clone()),
368        },
369    )?;
370    if &recomputed != effective {
371        return Err(stale_activation(
372            activation,
373            "effective policy no longer attenuates the exported policy".to_string(),
374        ));
375    }
376
377    let mut persona = discovered.persona.clone();
378    persona.autonomy_tier = Some(effective.autonomy_tier);
379    persona.tools.clone_from(&effective.tools);
380    persona.capabilities.clone_from(&effective.capabilities);
381    Ok(persona)
382}
383
384fn stale_activation(
385    activation: &PersonaActivationRecord,
386    reason: String,
387) -> PersonaActivationError {
388    PersonaActivationError::StaleActivation {
389        persona_id: activation.persona_id.clone(),
390        reason,
391    }
392}
393
394fn activation_record(
395    discovered: &DiscoverablePersona,
396    attenuation: &PersonaAttenuation,
397    now_ms: i64,
398) -> Result<PersonaActivationRecord, PersonaActivationError> {
399    let provenance = discovered
400        .installed_provenance()
401        .ok_or_else(|| PersonaActivationError::RootPersona(discovered.id.clone()))?;
402    let content_hash = provenance
403        .content_hash
404        .clone()
405        .filter(|value| !value.trim().is_empty())
406        .ok_or_else(|| PersonaActivationError::MissingContentHash(discovered.id.clone()))?;
407    let lock_digest = provenance
408        .lock_digest
409        .clone()
410        .filter(|value| !value.trim().is_empty())
411        .ok_or_else(|| PersonaActivationError::MissingLockDigest(discovered.id.clone()))?;
412    if !matches!(provenance.integrity.as_str(), "ok" | "observed") {
413        return Err(PersonaActivationError::PackageIntegrity {
414            persona_id: discovered.id.clone(),
415            integrity: provenance.integrity.clone(),
416        });
417    }
418    let exported_policy = exported_policy_contract(&discovered.persona, provenance)?;
419    let effective_policy = effective_policy(&discovered.persona, attenuation)?;
420    Ok(PersonaActivationRecord {
421        persona_id: discovered.id.clone(),
422        package: PersonaActivationPackage {
423            alias: provenance.package_alias.clone(),
424            version: provenance.package_version.clone(),
425            content_hash,
426            lock_digest,
427            source: provenance.source.clone(),
428            manifest_path: discovered.manifest_path.display().to_string(),
429        },
430        exported_policy_digest: policy_digest(&exported_policy)?,
431        effective_policy_digest: policy_digest(&effective_policy)?,
432        effective_policy,
433        migration: None,
434        activated_at_ms: now_ms,
435    })
436}
437
438fn effective_policy(
439    persona: &PersonaManifestEntry,
440    attenuation: &PersonaAttenuation,
441) -> Result<PersonaEffectivePolicy, PersonaActivationError> {
442    let exported_autonomy = persona.autonomy_tier.ok_or_else(|| {
443        PersonaActivationError::InvalidAttenuation("exported autonomy tier is missing".to_string())
444    })?;
445    let autonomy_tier = attenuation.autonomy_tier.unwrap_or(exported_autonomy);
446    if autonomy_tier > exported_autonomy {
447        return Err(PersonaActivationError::InvalidAttenuation(format!(
448            "autonomy {} exceeds exported {}",
449            autonomy_tier.as_str(),
450            exported_autonomy.as_str()
451        )));
452    }
453    let exported_capabilities = normalized_persona_capabilities(persona);
454    Ok(PersonaEffectivePolicy {
455        autonomy_tier,
456        tools: attenuate_set("tool", &persona.tools, attenuation.tools.as_deref())?,
457        capabilities: attenuate_set(
458            "capability",
459            &exported_capabilities,
460            attenuation.capabilities.as_deref(),
461        )?,
462    })
463}
464
465fn exported_policy_contract(
466    persona: &PersonaManifestEntry,
467    provenance: &super::InstalledPersonaProvenance,
468) -> Result<PersonaExportContract, PersonaActivationError> {
469    persona.autonomy_tier.ok_or_else(|| {
470        PersonaActivationError::InvalidAttenuation("exported autonomy tier is missing".to_string())
471    })?;
472    persona.receipt_policy.ok_or_else(|| {
473        PersonaActivationError::InvalidAttenuation("exported receipt policy is missing".to_string())
474    })?;
475    let mut persona = persona.clone();
476    persona.tools = normalize_set(&persona.tools);
477    persona.capabilities = normalized_persona_capabilities(&persona);
478    Ok(PersonaExportContract {
479        persona,
480        permissions: normalize_set(&provenance.permissions),
481        host_requirements: normalize_set(&provenance.host_requirements),
482    })
483}
484
485pub(crate) fn normalized_persona_capabilities(persona: &PersonaManifestEntry) -> Vec<String> {
486    let mut capabilities = persona.capabilities.clone();
487    if persona.model_policy.default_model.is_some()
488        || persona.model_policy.escalation_model.is_some()
489        || !persona.model_policy.fallback_models.is_empty()
490    {
491        capabilities.push("llm.call".to_string());
492    }
493    normalize_set(&capabilities)
494}
495
496fn attenuate_set(
497    kind: &str,
498    exported: &[String],
499    requested: Option<&[String]>,
500) -> Result<Vec<String>, PersonaActivationError> {
501    let exported = normalize_set(exported);
502    let Some(requested) = requested else {
503        return Ok(exported);
504    };
505    if requested.iter().any(|value| value.trim().is_empty()) {
506        return Err(PersonaActivationError::InvalidAttenuation(format!(
507            "{kind} names must not be empty"
508        )));
509    }
510    let requested = normalize_set(requested);
511    if let Some(extra) = requested.iter().find(|value| !exported.contains(value)) {
512        return Err(PersonaActivationError::InvalidAttenuation(format!(
513            "{kind} '{extra}' is not exported"
514        )));
515    }
516    Ok(requested)
517}
518
519fn normalize_set(values: &[String]) -> Vec<String> {
520    let mut values = values
521        .iter()
522        .map(|value| value.trim())
523        .filter(|value| !value.is_empty())
524        .map(str::to_string)
525        .collect::<Vec<_>>();
526    values.sort();
527    values.dedup();
528    values
529}
530
531fn policy_digest(policy: &impl Serialize) -> Result<String, PersonaActivationError> {
532    let bytes = serde_json::to_vec(policy)?;
533    Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
534}
535
536fn decode_activation_ledger(
537    path: &Path,
538    bytes: &[u8],
539) -> Result<PersonaActivationLedger, PersonaActivationError> {
540    let probe: ActivationLedgerVersion =
541        serde_json::from_slice(bytes).map_err(|error| invalid_ledger(path, error.to_string()))?;
542    match probe.schema_version {
543        ACTIVATION_SCHEMA_VERSION => {
544            serde_json::from_slice(bytes).map_err(|error| invalid_ledger(path, error.to_string()))
545        }
546        LEGACY_ACTIVATION_SCHEMA_VERSION => {
547            let legacy: LegacyPersonaActivationLedgerV1 = serde_json::from_slice(bytes)
548                .map_err(|error| invalid_ledger(path, error.to_string()))?;
549            migrate_legacy_activation_ledger(path, legacy)
550        }
551        actual => Err(PersonaActivationError::UnsupportedSchema {
552            path: path.display().to_string(),
553            actual,
554            expected: ACTIVATION_SCHEMA_VERSION,
555        }),
556    }
557}
558
559fn migrate_legacy_activation_ledger(
560    path: &Path,
561    legacy: LegacyPersonaActivationLedgerV1,
562) -> Result<PersonaActivationLedger, PersonaActivationError> {
563    if legacy.schema_version != LEGACY_ACTIVATION_SCHEMA_VERSION {
564        return Err(PersonaActivationError::UnsupportedSchema {
565            path: path.display().to_string(),
566            actual: legacy.schema_version,
567            expected: ACTIVATION_SCHEMA_VERSION,
568        });
569    }
570    let mut activations = BTreeMap::new();
571    for (id, record) in legacy.activations {
572        if id != record.persona_id {
573            return Err(invalid_ledger(
574                path,
575                format!(
576                    "activation key '{id}' does not match record id '{}'",
577                    record.persona_id
578                ),
579            ));
580        }
581        if record.package.content_hash.trim().is_empty() {
582            return Err(invalid_ledger(
583                path,
584                format!("activation '{id}' has an empty content hash"),
585            ));
586        }
587        let legacy_digest = policy_digest(&record.effective_policy)?;
588        if legacy_digest != record.effective_policy_digest {
589            return Err(invalid_ledger(
590                path,
591                format!("activation '{id}' legacy effective policy digest does not match"),
592            ));
593        }
594        let effective_policy = PersonaEffectivePolicy {
595            autonomy_tier: record.effective_policy.autonomy_tier,
596            tools: normalize_set(&record.effective_policy.tools),
597            capabilities: normalize_set(&record.effective_policy.capabilities),
598        };
599        let effective_policy_digest = policy_digest(&effective_policy)?;
600        let not_enforced_policy = serde_json::to_value(&record.effective_policy)?;
601        activations.insert(
602            id,
603            PersonaActivationRecord {
604                persona_id: record.persona_id,
605                package: record.package,
606                exported_policy_digest: record.exported_policy_digest,
607                effective_policy_digest,
608                effective_policy,
609                migration: Some(PersonaActivationMigration {
610                    status: PersonaActivationMigrationStatus::ReactivationRequired,
611                    source_schema_version: LEGACY_ACTIVATION_SCHEMA_VERSION,
612                    legacy_effective_policy_digest: legacy_digest,
613                    not_enforced_policy,
614                }),
615                activated_at_ms: record.activated_at_ms,
616            },
617        );
618    }
619    Ok(PersonaActivationLedger {
620        schema_version: ACTIVATION_SCHEMA_VERSION,
621        activations,
622    })
623}
624
625fn validate_ledger(
626    path: &Path,
627    ledger: &PersonaActivationLedger,
628) -> Result<(), PersonaActivationError> {
629    if ledger.schema_version != ACTIVATION_SCHEMA_VERSION {
630        return Err(PersonaActivationError::UnsupportedSchema {
631            path: path.display().to_string(),
632            actual: ledger.schema_version,
633            expected: ACTIVATION_SCHEMA_VERSION,
634        });
635    }
636    for (id, activation) in &ledger.activations {
637        if id != &activation.persona_id {
638            return Err(invalid_ledger(
639                path,
640                format!(
641                    "activation key '{id}' does not match record id '{}'",
642                    activation.persona_id
643                ),
644            ));
645        }
646        if activation.package.content_hash.trim().is_empty() {
647            return Err(invalid_ledger(
648                path,
649                format!("activation '{id}' has an empty content hash"),
650            ));
651        }
652        if activation.migration.is_none() && activation.package.lock_digest.trim().is_empty() {
653            return Err(invalid_ledger(
654                path,
655                format!("activation '{id}' has an empty package-generation lock digest"),
656            ));
657        }
658        let actual_digest = policy_digest(&activation.effective_policy)?;
659        if actual_digest != activation.effective_policy_digest {
660            return Err(invalid_ledger(
661                path,
662                format!("activation '{id}' effective policy digest does not match its policy"),
663            ));
664        }
665        if let Some(migration) = &activation.migration {
666            if migration.source_schema_version != LEGACY_ACTIVATION_SCHEMA_VERSION {
667                return Err(invalid_ledger(
668                    path,
669                    format!(
670                        "activation '{id}' migration has unsupported source schema version {}",
671                        migration.source_schema_version
672                    ),
673                ));
674            }
675            let legacy_policy: LegacyPersonaEffectivePolicyV1 =
676                serde_json::from_value(migration.not_enforced_policy.clone())
677                    .map_err(|error| invalid_ledger(path, error.to_string()))?;
678            let legacy_digest = policy_digest(&legacy_policy)?;
679            if legacy_digest != migration.legacy_effective_policy_digest {
680                return Err(invalid_ledger(
681                    path,
682                    format!("activation '{id}' migrated policy digest does not match its archive"),
683                ));
684            }
685        }
686    }
687    Ok(())
688}
689
690fn mutate_activation_ledger<T>(
691    project_root: &Path,
692    mutate: impl FnOnce(&mut PersonaActivationLedger) -> (bool, T),
693) -> Result<(bool, T), PersonaActivationError> {
694    let path = activation_ledger_path(project_root);
695    let lock_path = project_root.join(ACTIVATION_DIR).join(ACTIVATION_LOCK_FILE);
696    fs::create_dir_all(lock_path.parent().unwrap_or(project_root))
697        .map_err(|source| io_error("create", &lock_path, source))?;
698    let lock = open_lock_file(&lock_path)?;
699    lock.lock_exclusive()
700        .map_err(|source| io_error("lock", &lock_path, source))?;
701    let result = (|| {
702        let mut ledger = load_activation_ledger(project_root)?;
703        let (changed, value) = mutate(&mut ledger);
704        if changed {
705            write_activation_ledger(&path, &ledger)?;
706        }
707        Ok((changed, value))
708    })();
709    let unlock_result =
710        FileExt::unlock(&lock).map_err(|source| io_error("unlock", &lock_path, source));
711    match (result, unlock_result) {
712        (Err(error), _) => Err(error),
713        (Ok(_), Err(error)) => Err(error),
714        (Ok(value), Ok(())) => Ok(value),
715    }
716}
717
718fn open_lock_file(path: &Path) -> Result<File, PersonaActivationError> {
719    OpenOptions::new()
720        .create(true)
721        .truncate(false)
722        .read(true)
723        .write(true)
724        .open(path)
725        .map_err(|source| io_error("open", path, source))
726}
727
728fn write_activation_ledger(
729    path: &Path,
730    ledger: &PersonaActivationLedger,
731) -> Result<(), PersonaActivationError> {
732    let mut bytes = serde_json::to_vec_pretty(ledger)?;
733    bytes.push(b'\n');
734    harn_vm::atomic_io::atomic_write(path, &bytes).map_err(|source| io_error("write", path, source))
735}
736
737fn invalid_ledger(path: &Path, message: String) -> PersonaActivationError {
738    PersonaActivationError::InvalidLedger {
739        path: path.display().to_string(),
740        message,
741    }
742}
743
744fn io_error(operation: &'static str, path: &Path, source: io::Error) -> PersonaActivationError {
745    PersonaActivationError::Io {
746        operation,
747        path: path.display().to_string(),
748        source,
749    }
750}
751
752#[cfg(test)]
753#[path = "persona_activation_tests.rs"]
754mod tests;