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