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