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