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