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