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