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 pub tools: Option<Vec<String>>,
112 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 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;