Skip to main content

lenso_app_authoring/
configuration_authority.rs

1use std::{
2    error::Error,
3    fmt,
4    fmt::Write as _,
5    fs,
6    path::{Path, PathBuf},
7    str,
8    str::FromStr,
9    sync::{Arc, Mutex},
10};
11
12use crate::host_authoring::{HOST_BUILD, HostInput};
13use anyhow::{Context, bail};
14use lenso_app_plan::authoring::{
15    DependencyChoice, PluginInstanceId, PluginRootInstance, PluginRootResolutionError,
16    PluginRootSnapshot, ResolvedApp,
17};
18use serde::Serialize;
19use sha2::{Digest, Sha256};
20
21use super::{
22    DEPENDENCY_SELECTIONS, DEPENDENCY_SELECTIONS_SCHEMA_VERSION, DependencySelectionsDocument,
23    HOST_CATALOG, LEGACY_DEPENDENCY_SELECTIONS, MAX_CONFIGURATION_BYTES, PLUGIN_ROOT,
24    PluginRootAuthoringState, inspect_plugin_root, load_host_catalog, lock_plugin_root,
25    root_transaction, snapshot_plugin_root, validate_existing_plugin_id,
26    validate_instance_filename, validate_requirement_id,
27};
28
29const PROPOSAL_SCHEMA: &str = "lenso.plugin-configuration-proposal.v1";
30const PUBLICATION_SCHEMA: &str = "lenso.plugin-configuration-publication.v1";
31const SOURCE_DIGEST_SCHEMA: &str = "lenso.plugin-configuration-source.v1";
32const ROOT_CHANGE_PROPOSAL_SCHEMA: &str = "lenso.plugin-root-change-proposal.v1";
33const ROOT_CHANGE_PUBLICATION_SCHEMA: &str = "lenso.plugin-root-change-publication.v1";
34const ROOT_SOURCE_DIGEST_SCHEMA: &str = "lenso.plugin-root-source.v1";
35
36/// Stable provenance for the authority that owns Plugin configuration publication.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct PluginConfigurationAuthoritySource {
39    kind: String,
40    reference: String,
41}
42
43impl PluginConfigurationAuthoritySource {
44    /// Creates one Host-trusted authority identity.
45    pub fn new(kind: impl Into<String>, reference: impl Into<String>) -> anyhow::Result<Self> {
46        let kind = kind.into();
47        let reference = reference.into();
48        if kind.is_empty()
49            || kind.len() > 64
50            || !kind.bytes().all(|byte| {
51                byte.is_ascii_lowercase()
52                    || byte.is_ascii_digit()
53                    || matches!(byte, b'_' | b'-' | b'.')
54            })
55        {
56            bail!("Plugin configuration authority kind is invalid");
57        }
58        if reference.is_empty() || reference.len() > 256 || reference.chars().any(char::is_control)
59        {
60            bail!("Plugin configuration authority reference is invalid");
61        }
62        Ok(Self { kind, reference })
63    }
64
65    pub fn kind(&self) -> &str {
66        &self.kind
67    }
68
69    pub fn reference(&self) -> &str {
70        &self.reference
71    }
72}
73
74/// Host-side port for inspecting, proposing, and publishing Plugin configuration.
75///
76/// Implementations own authoring storage and compare-and-swap publication. They
77/// do not own App Generation staging, routing, or Kernel execution.
78pub trait PluginConfigurationAuthority: fmt::Debug + Send + Sync {
79    fn source(&self) -> PluginConfigurationAuthoritySource;
80
81    fn inspect(&self) -> anyhow::Result<PluginRootAuthoringState>;
82
83    fn propose(
84        &self,
85        expected_revision: &PluginRootRevision,
86        plugin_id: &str,
87        instance: &str,
88        bytes: &[u8],
89    ) -> anyhow::Result<PluginConfigurationProposal>;
90
91    fn publish(
92        &self,
93        proposal: &PluginConfigurationProposal,
94    ) -> anyhow::Result<PluginConfigurationPublication>;
95
96    fn propose_changes(
97        &self,
98        expected_revision: &PluginRootRevision,
99        changes: PluginRootChangeSet,
100    ) -> anyhow::Result<PluginRootChangeProposal>;
101
102    fn publish_changes(
103        &self,
104        proposal: &PluginRootChangeProposal,
105    ) -> anyhow::Result<PluginRootChangePublication>;
106}
107
108/// Default configuration authority backed by one visible App Plugin Root.
109#[derive(Clone, Debug)]
110pub struct LocalPluginRootAuthority {
111    root: PathBuf,
112    access: Arc<Mutex<()>>,
113}
114
115impl LocalPluginRootAuthority {
116    pub fn new(root: impl Into<PathBuf>) -> Self {
117        Self {
118            root: root.into(),
119            access: Arc::new(Mutex::new(())),
120        }
121    }
122
123    pub fn root(&self) -> &Path {
124        &self.root
125    }
126
127    pub(crate) fn lock(&self) -> anyhow::Result<std::sync::MutexGuard<'_, ()>> {
128        self.access
129            .lock()
130            .map_err(|_| anyhow::anyhow!("Plugin configuration authority lock is poisoned"))
131    }
132}
133
134impl PluginConfigurationAuthority for LocalPluginRootAuthority {
135    fn source(&self) -> PluginConfigurationAuthoritySource {
136        PluginConfigurationAuthoritySource {
137            kind: "local_plugin_root".to_owned(),
138            reference: "app".to_owned(),
139        }
140    }
141
142    fn inspect(&self) -> anyhow::Result<PluginRootAuthoringState> {
143        let _guard = self.lock()?;
144        inspect_plugin_root(&self.root)
145    }
146
147    fn propose(
148        &self,
149        expected_revision: &PluginRootRevision,
150        plugin_id: &str,
151        instance: &str,
152        bytes: &[u8],
153    ) -> anyhow::Result<PluginConfigurationProposal> {
154        let _guard = self.lock()?;
155        propose_instance_configuration(&self.root, expected_revision, plugin_id, instance, bytes)
156    }
157
158    fn publish(
159        &self,
160        proposal: &PluginConfigurationProposal,
161    ) -> anyhow::Result<PluginConfigurationPublication> {
162        let _guard = self.lock()?;
163        publish_instance_configuration(&self.root, proposal)
164    }
165
166    fn propose_changes(
167        &self,
168        expected_revision: &PluginRootRevision,
169        changes: PluginRootChangeSet,
170    ) -> anyhow::Result<PluginRootChangeProposal> {
171        let _guard = self.lock()?;
172        propose_plugin_root_changes(&self.root, expected_revision, changes)
173    }
174
175    fn publish_changes(
176        &self,
177        proposal: &PluginRootChangeProposal,
178    ) -> anyhow::Result<PluginRootChangePublication> {
179        let _guard = self.lock()?;
180        publish_plugin_root_changes(&self.root, proposal)
181    }
182}
183
184/// A deterministic semantic revision of one App's Plugin Root.
185///
186/// Formatting and comments in TOML do not change this revision. The parsed
187/// desired state is the authority used for compare-and-swap publication.
188#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
189pub struct PluginRootRevision(String);
190
191impl PluginRootRevision {
192    pub fn as_str(&self) -> &str {
193        &self.0
194    }
195}
196
197impl fmt::Display for PluginRootRevision {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        formatter.write_str(&self.0)
200    }
201}
202
203impl FromStr for PluginRootRevision {
204    type Err = PluginRootRevisionParseError;
205
206    fn from_str(value: &str) -> Result<Self, Self::Err> {
207        let Some(digest) = value.strip_prefix("sha256:") else {
208            return Err(PluginRootRevisionParseError);
209        };
210        if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
211            return Err(PluginRootRevisionParseError);
212        }
213        Ok(Self(format!("sha256:{}", digest.to_ascii_lowercase())))
214    }
215}
216
217/// Text did not contain one complete SHA-256 Plugin Root revision.
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub struct PluginRootRevisionParseError;
220
221impl fmt::Display for PluginRootRevisionParseError {
222    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
223        formatter.write_str("Plugin Root revision must be `sha256:` followed by 64 hex digits")
224    }
225}
226
227impl Error for PluginRootRevisionParseError {}
228
229/// A proposal was published against a Plugin Root that has since changed.
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct PluginRootRevisionConflict {
232    expected: PluginRootRevision,
233    current: PluginRootRevision,
234}
235
236impl PluginRootRevisionConflict {
237    pub const fn expected(&self) -> &PluginRootRevision {
238        &self.expected
239    }
240
241    pub const fn current(&self) -> &PluginRootRevision {
242        &self.current
243    }
244}
245
246impl fmt::Display for PluginRootRevisionConflict {
247    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248        write!(
249            formatter,
250            "Plugin Root revision conflict: expected {}, current {}",
251            self.expected, self.current
252        )
253    }
254}
255
256impl Error for PluginRootRevisionConflict {}
257
258/// One exact TOML source to include in a coordinated Plugin Root change.
259#[derive(Clone, Debug, Serialize)]
260pub struct PluginRootConfigurationChange {
261    plugin_id: String,
262    instance_key: String,
263    toml: Vec<u8>,
264}
265
266impl PluginRootConfigurationChange {
267    pub fn new(
268        plugin_id: impl Into<String>,
269        instance: impl Into<String>,
270        toml: impl Into<Vec<u8>>,
271    ) -> Self {
272        Self {
273            plugin_id: plugin_id.into(),
274            instance_key: instance.into(),
275            toml: toml.into(),
276        }
277    }
278
279    pub fn plugin_id(&self) -> &str {
280        &self.plugin_id
281    }
282
283    pub fn instance_key(&self) -> &str {
284        &self.instance_key
285    }
286
287    pub fn toml(&self) -> &[u8] {
288        &self.toml
289    }
290}
291
292/// A complete change request that can coordinate multiple configurations and choices.
293#[derive(Clone, Debug, Default)]
294pub struct PluginRootChangeSet {
295    configurations: Vec<PluginRootConfigurationChange>,
296    dependency_choices: Option<Vec<DependencyChoice>>,
297}
298
299impl PluginRootChangeSet {
300    pub const fn new() -> Self {
301        Self {
302            configurations: Vec::new(),
303            dependency_choices: None,
304        }
305    }
306
307    #[must_use]
308    pub fn with_configuration(mut self, change: PluginRootConfigurationChange) -> Self {
309        self.configurations.push(change);
310        self
311    }
312
313    /// Replaces the complete persisted choice set. An empty list adopts explicit empty intent.
314    #[must_use]
315    pub fn with_dependency_choices(
316        mut self,
317        choices: impl IntoIterator<Item = DependencyChoice>,
318    ) -> Self {
319        self.dependency_choices = Some(choices.into_iter().collect());
320        self
321    }
322
323    pub fn configurations(&self) -> &[PluginRootConfigurationChange] {
324        &self.configurations
325    }
326
327    pub fn dependency_choices(&self) -> Option<&[DependencyChoice]> {
328        self.dependency_choices.as_deref()
329    }
330}
331
332/// Digest of one exact Plugin Root source path, including absence.
333#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct PluginRootSourceDigest {
335    path: String,
336    digest: String,
337}
338
339impl PluginRootSourceDigest {
340    pub fn path(&self) -> &str {
341        &self.path
342    }
343
344    pub fn digest(&self) -> &str {
345        &self.digest
346    }
347}
348
349/// Exact old-to-new public requirement identity mapping in a reviewed migration.
350#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
351pub struct PluginRequirementMigration {
352    consumer: PluginInstanceId,
353    old_requirement_id: String,
354    new_requirement_ids: Vec<String>,
355    provider: Option<PluginInstanceId>,
356}
357
358impl PluginRequirementMigration {
359    pub const fn consumer(&self) -> &PluginInstanceId {
360        &self.consumer
361    }
362
363    pub fn old_requirement_id(&self) -> &str {
364        &self.old_requirement_id
365    }
366
367    pub fn new_requirement_ids(&self) -> &[String] {
368        &self.new_requirement_ids
369    }
370
371    pub const fn provider(&self) -> Option<&PluginInstanceId> {
372        self.provider.as_ref()
373    }
374}
375
376/// Immutable review evidence for one coordinated Plugin Root publication.
377#[derive(Clone, Debug)]
378pub struct PluginRootChangeProposal {
379    schema: &'static str,
380    base_revision: PluginRootRevision,
381    host_catalog_digest: String,
382    source_digests: Vec<PluginRootSourceDigest>,
383    candidate_revision: PluginRootRevision,
384    digest: String,
385    status: PluginConfigurationProposalStatus,
386    application: PluginConfigurationApplication,
387    diagnostics: Vec<PluginConfigurationDiagnostic>,
388    requirement_migrations: Vec<PluginRequirementMigration>,
389    changes: PluginRootChangeSet,
390    materialized_choices: Option<Vec<DependencyChoice>>,
391}
392
393impl PluginRootChangeProposal {
394    pub const fn schema(&self) -> &str {
395        self.schema
396    }
397
398    pub const fn base_revision(&self) -> &PluginRootRevision {
399        &self.base_revision
400    }
401
402    pub fn host_catalog_digest(&self) -> &str {
403        &self.host_catalog_digest
404    }
405
406    pub fn source_digests(&self) -> &[PluginRootSourceDigest] {
407        &self.source_digests
408    }
409
410    pub const fn candidate_revision(&self) -> &PluginRootRevision {
411        &self.candidate_revision
412    }
413
414    pub fn digest(&self) -> &str {
415        &self.digest
416    }
417
418    pub const fn status(&self) -> PluginConfigurationProposalStatus {
419        self.status
420    }
421
422    pub const fn application(&self) -> PluginConfigurationApplication {
423        self.application
424    }
425
426    pub fn diagnostics(&self) -> &[PluginConfigurationDiagnostic] {
427        &self.diagnostics
428    }
429
430    pub fn requirement_migrations(&self) -> &[PluginRequirementMigration] {
431        &self.requirement_migrations
432    }
433
434    pub const fn changes(&self) -> &PluginRootChangeSet {
435        &self.changes
436    }
437
438    pub fn materialized_dependency_choices(&self) -> Option<&[DependencyChoice]> {
439        self.materialized_choices.as_deref()
440    }
441}
442
443/// Evidence returned after one coordinated Plugin Root transaction commits.
444#[derive(Clone, Debug)]
445pub struct PluginRootChangePublication {
446    schema: &'static str,
447    base_revision: PluginRootRevision,
448    revision: PluginRootRevision,
449    proposal_digest: String,
450    resolved: ResolvedApp,
451}
452
453impl PluginRootChangePublication {
454    pub const fn schema(&self) -> &str {
455        self.schema
456    }
457
458    pub const fn base_revision(&self) -> &PluginRootRevision {
459        &self.base_revision
460    }
461
462    pub const fn revision(&self) -> &PluginRootRevision {
463        &self.revision
464    }
465
466    pub fn proposal_digest(&self) -> &str {
467        &self.proposal_digest
468    }
469
470    pub const fn resolved(&self) -> &ResolvedApp {
471        &self.resolved
472    }
473
474    pub fn into_resolved(self) -> ResolvedApp {
475        self.resolved
476    }
477}
478
479/// Read-only review evidence for one exact Plugin Instance configuration change.
480#[derive(Clone, Debug)]
481pub struct PluginConfigurationProposal {
482    schema: &'static str,
483    base_revision: PluginRootRevision,
484    base_source_digest: PluginConfigurationSourceDigest,
485    candidate_revision: PluginRootRevision,
486    digest: String,
487    status: PluginConfigurationProposalStatus,
488    application: PluginConfigurationApplication,
489    diagnostics: Vec<PluginConfigurationDiagnostic>,
490    plugin_id: String,
491    instance_key: String,
492    toml: Vec<u8>,
493    root_proposal: PluginRootChangeProposal,
494}
495
496impl PluginConfigurationProposal {
497    pub const fn schema(&self) -> &str {
498        self.schema
499    }
500
501    pub const fn base_revision(&self) -> &PluginRootRevision {
502        &self.base_revision
503    }
504
505    pub const fn base_source_digest(&self) -> &PluginConfigurationSourceDigest {
506        &self.base_source_digest
507    }
508
509    pub const fn candidate_revision(&self) -> &PluginRootRevision {
510        &self.candidate_revision
511    }
512
513    pub fn digest(&self) -> &str {
514        &self.digest
515    }
516
517    pub const fn status(&self) -> PluginConfigurationProposalStatus {
518        self.status
519    }
520
521    pub const fn application(&self) -> PluginConfigurationApplication {
522        self.application
523    }
524
525    pub fn diagnostics(&self) -> &[PluginConfigurationDiagnostic] {
526        &self.diagnostics
527    }
528
529    pub fn plugin_id(&self) -> &str {
530        &self.plugin_id
531    }
532
533    pub fn instance_key(&self) -> &str {
534        &self.instance_key
535    }
536}
537
538/// Whether a configuration proposal can proceed to publication.
539#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540pub enum PluginConfigurationProposalStatus {
541    Ready,
542    NeedsDecision,
543    Rejected,
544}
545
546/// The Host action required after publication.
547#[derive(Clone, Copy, Debug, Eq, PartialEq)]
548pub enum PluginConfigurationApplication {
549    Noop,
550    AppGeneration,
551    Blocked,
552}
553
554/// One stable reason a configuration proposal cannot proceed automatically.
555#[derive(Clone, Debug, Eq, PartialEq)]
556pub struct PluginConfigurationDiagnostic {
557    code: &'static str,
558    detail: String,
559}
560
561impl PluginConfigurationDiagnostic {
562    pub const fn code(&self) -> &str {
563        self.code
564    }
565
566    pub fn detail(&self) -> &str {
567        &self.detail
568    }
569}
570
571/// Evidence returned after compare-and-swap publication succeeds.
572#[derive(Clone, Debug)]
573pub struct PluginConfigurationPublication {
574    schema: &'static str,
575    base_revision: PluginRootRevision,
576    base_source_digest: PluginConfigurationSourceDigest,
577    revision: PluginRootRevision,
578    proposal_digest: String,
579    resolved: ResolvedApp,
580}
581
582impl PluginConfigurationPublication {
583    pub const fn schema(&self) -> &str {
584        self.schema
585    }
586
587    pub const fn base_revision(&self) -> &PluginRootRevision {
588        &self.base_revision
589    }
590
591    pub const fn base_source_digest(&self) -> &PluginConfigurationSourceDigest {
592        &self.base_source_digest
593    }
594
595    pub const fn revision(&self) -> &PluginRootRevision {
596        &self.revision
597    }
598
599    pub fn proposal_digest(&self) -> &str {
600        &self.proposal_digest
601    }
602
603    pub const fn resolved(&self) -> &ResolvedApp {
604        &self.resolved
605    }
606
607    pub fn into_resolved(self) -> ResolvedApp {
608        self.resolved
609    }
610}
611
612/// Builds review evidence for a coordinated set of Plugin Root changes without publishing it.
613pub fn propose_plugin_root_changes(
614    root: &Path,
615    expected_revision: &PluginRootRevision,
616    changes: PluginRootChangeSet,
617) -> anyhow::Result<PluginRootChangeProposal> {
618    let _lock = lock_plugin_root(root)?;
619    let host = load_host_catalog(root)?;
620    let current = snapshot_plugin_root(root, &host)?;
621    let current_revision = revision_for_snapshot(&current)?;
622    ensure_revision(expected_revision, &current_revision)?;
623    build_root_change_proposal(root, &host, &current, current_revision, changes)
624}
625
626/// Publishes one reviewed multi-file change with revision, source, and Host fencing.
627pub fn publish_plugin_root_changes(
628    root: &Path,
629    proposal: &PluginRootChangeProposal,
630) -> anyhow::Result<PluginRootChangePublication> {
631    let _lock = lock_plugin_root(root)?;
632    let host = load_host_catalog(root)?;
633    let current = snapshot_plugin_root(root, &host)?;
634    let current_revision = revision_for_snapshot(&current)?;
635    ensure_revision(&proposal.base_revision, &current_revision)?;
636    let current_host_digest = host_catalog_digest(root)?;
637    if current_host_digest != proposal.host_catalog_digest {
638        bail!("Host Catalog changed after the Plugin Root proposal was reviewed");
639    }
640    let current_sources = source_digests_for_changes(root, &proposal.changes)?;
641    if current_sources != proposal.source_digests {
642        bail!("Plugin Root source bytes changed after the proposal was reviewed");
643    }
644    let verified = build_root_change_proposal(
645        root,
646        &host,
647        &current,
648        current_revision,
649        proposal.changes.clone(),
650    )?;
651    if proposal.candidate_revision != verified.candidate_revision
652        || proposal.digest != verified.digest
653        || proposal.materialized_choices != verified.materialized_choices
654    {
655        bail!("Plugin Root proposal no longer matches its reviewed candidate");
656    }
657    ensure_ready(
658        verified.status,
659        verified.application,
660        &verified.diagnostics,
661        "Plugin Root proposal",
662    )?;
663
664    let mut files = verified
665        .changes
666        .configurations
667        .iter()
668        .map(|change| {
669            root_transaction::RootFileChange::write(
670                PathBuf::from(&change.plugin_id).join(format!("{}.toml", change.instance_key)),
671                change.toml.clone(),
672            )
673        })
674        .collect::<Vec<_>>();
675    if let Some(choices) = &verified.materialized_choices {
676        let document = DependencySelectionsDocument {
677            schema_version: DEPENDENCY_SELECTIONS_SCHEMA_VERSION,
678            choices: choices.clone(),
679        };
680        files.push(root_transaction::RootFileChange::write(
681            DEPENDENCY_SELECTIONS,
682            serde_json::to_vec_pretty(&document).context("encode dependency selections")?,
683        ));
684        files.push(root_transaction::RootFileChange::remove(
685            LEGACY_DEPENDENCY_SELECTIONS,
686        ));
687    }
688    root_transaction::publish_root_files(root, files)?;
689
690    let published = snapshot_plugin_root(root, &host)?;
691    let revision = revision_for_snapshot(&published)?;
692    if revision != proposal.candidate_revision {
693        bail!("published Plugin Root does not match the reviewed candidate revision");
694    }
695    let resolved = host.resolve(&published).map_err(anyhow::Error::msg)?;
696    Ok(PluginRootChangePublication {
697        schema: ROOT_CHANGE_PUBLICATION_SCHEMA,
698        base_revision: proposal.base_revision.clone(),
699        revision,
700        proposal_digest: proposal.digest.clone(),
701        resolved,
702    })
703}
704
705fn build_root_change_proposal(
706    root: &Path,
707    host: &HostInput,
708    current: &PluginRootSnapshot,
709    base_revision: PluginRootRevision,
710    changes: PluginRootChangeSet,
711) -> anyhow::Result<PluginRootChangeProposal> {
712    let changes = normalize_change_set(changes)?;
713    let mut instances = current.instances().to_vec();
714    for change in &changes.configurations {
715        let id = PluginInstanceId::new(&change.plugin_id, &change.instance_key);
716        instances.retain(|instance| instance.id() != &id);
717        instances.push(
718            PluginRootInstance::new(&change.plugin_id, &change.instance_key)
719                .with_configuration(parse_configuration(&change.toml)?),
720        );
721    }
722    let mut candidate = PluginRootSnapshot::new(
723        current.releases().iter().cloned(),
724        instances,
725        current.disabled().iter().cloned(),
726    );
727    candidate = match &changes.dependency_choices {
728        Some(choices) => candidate.with_dependency_choices(choices.clone()),
729        None => crate::preserve_dependency_selections(candidate, current),
730    };
731
732    let mut materialized_choices = None;
733    let resolution = if changes.dependency_choices.is_some() {
734        match host.propose(&candidate) {
735            Ok(proposed) => {
736                let choices = proposed.dependency_choices().to_vec();
737                candidate = PluginRootSnapshot::new(
738                    candidate.releases().iter().cloned(),
739                    candidate.instances().iter().cloned(),
740                    candidate.disabled().iter().cloned(),
741                )
742                .with_dependency_choices(choices.clone());
743                materialized_choices = Some(choices);
744                host.resolve(&candidate)
745            }
746            Err(error) => Err(error),
747        }
748    } else {
749        host.resolve(&candidate)
750    };
751    if let Some(choices) = &materialized_choices {
752        validate_dependency_choices(choices)?;
753        let document = DependencySelectionsDocument {
754            schema_version: DEPENDENCY_SELECTIONS_SCHEMA_VERSION,
755            choices: choices.clone(),
756        };
757        let bytes = serde_json::to_vec(&document).context("encode dependency selections")?;
758        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > super::MAX_DEPENDENCY_SELECTION_BYTES {
759            bail!("Plugin dependency selections exceed 1 MiB");
760        }
761    }
762    let candidate_revision = revision_for_snapshot(&candidate)?;
763    let (mut status, mut application, mut diagnostics) =
764        classify_resolution(resolution, &candidate_revision, &base_revision);
765    let requirement_migrations = materialized_choices
766        .as_ref()
767        .map_or_else(Vec::new, |choices| {
768            requirement_migrations(current.dependency_choices(), choices)
769        });
770    if changes
771        .dependency_choices
772        .as_ref()
773        .is_some_and(|requested| has_unreviewed_split(&requirement_migrations, requested))
774    {
775        status = PluginConfigurationProposalStatus::NeedsDecision;
776        application = PluginConfigurationApplication::Blocked;
777        diagnostics.push(PluginConfigurationDiagnostic {
778            code: "migration_mapping_required",
779            detail: "a split requirement migration needs every new requirement mapped explicitly"
780                .to_owned(),
781        });
782    }
783    let host_catalog_digest = host_catalog_digest(root)?;
784    let source_digests = source_digests_for_changes(root, &changes)?;
785    let authority = serde_json::to_vec(&(
786        ROOT_CHANGE_PROPOSAL_SCHEMA,
787        base_revision.as_str(),
788        &host_catalog_digest,
789        source_digests
790            .iter()
791            .map(|source| (&source.path, &source.digest))
792            .collect::<Vec<_>>(),
793        candidate_revision.as_str(),
794        &changes.configurations,
795        &materialized_choices,
796        &requirement_migrations,
797    ))
798    .context("encode Plugin Root proposal authority")?;
799    Ok(PluginRootChangeProposal {
800        schema: ROOT_CHANGE_PROPOSAL_SCHEMA,
801        base_revision,
802        host_catalog_digest,
803        source_digests,
804        candidate_revision,
805        digest: sha256_digest(&authority),
806        status,
807        application,
808        diagnostics,
809        requirement_migrations,
810        changes,
811        materialized_choices,
812    })
813}
814
815fn has_unreviewed_split(
816    migrations: &[PluginRequirementMigration],
817    requested: &[DependencyChoice],
818) -> bool {
819    let requested_keys = requested
820        .iter()
821        .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
822        .collect::<std::collections::BTreeSet<_>>();
823    migrations.iter().any(|migration| {
824        migration.new_requirement_ids.len() > 1
825            && migration.new_requirement_ids.iter().any(|requirement_id| {
826                !requested_keys.contains(&(&migration.consumer, requirement_id.as_str()))
827            })
828    })
829}
830
831fn normalize_change_set(mut changes: PluginRootChangeSet) -> anyhow::Result<PluginRootChangeSet> {
832    if changes.configurations.is_empty() && changes.dependency_choices.is_none() {
833        bail!("Plugin Root proposal must contain at least one change");
834    }
835    let mut identities = std::collections::BTreeSet::new();
836    for change in &changes.configurations {
837        validate_existing_plugin_id(&change.plugin_id)?;
838        validate_instance_filename(&change.instance_key)?;
839        parse_configuration(&change.toml)?;
840        if !identities.insert((change.plugin_id.clone(), change.instance_key.clone())) {
841            bail!(
842                "duplicate Plugin configuration change for `{}/{}`",
843                change.plugin_id,
844                change.instance_key
845            );
846        }
847    }
848    changes.configurations.sort_by(|left, right| {
849        left.plugin_id
850            .cmp(&right.plugin_id)
851            .then_with(|| left.instance_key.cmp(&right.instance_key))
852    });
853    if let Some(choices) = &mut changes.dependency_choices {
854        validate_dependency_choices(choices)?;
855        choices.sort_by(|left, right| {
856            left.consumer
857                .cmp(&right.consumer)
858                .then_with(|| left.requirement_id.cmp(&right.requirement_id))
859        });
860    }
861    Ok(changes)
862}
863
864fn requirement_migrations(
865    current: &[DependencyChoice],
866    candidate: &[DependencyChoice],
867) -> Vec<PluginRequirementMigration> {
868    let current_keys = current
869        .iter()
870        .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
871        .collect::<std::collections::BTreeSet<_>>();
872    let candidate_keys = candidate
873        .iter()
874        .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
875        .collect::<std::collections::BTreeSet<_>>();
876    current
877        .iter()
878        .filter(|choice| {
879            !candidate_keys.contains(&(&choice.consumer, choice.requirement_id.as_str()))
880        })
881        .map(|old| {
882            let mut new_requirement_ids = candidate
883                .iter()
884                .filter(|new| {
885                    !current_keys.contains(&(&new.consumer, new.requirement_id.as_str()))
886                        && new.consumer == old.consumer
887                        && new.provider == old.provider
888                })
889                .map(|choice| choice.requirement_id.clone())
890                .collect::<Vec<_>>();
891            new_requirement_ids.sort();
892            PluginRequirementMigration {
893                consumer: old.consumer.clone(),
894                old_requirement_id: old.requirement_id.clone(),
895                new_requirement_ids,
896                provider: old.provider.clone(),
897            }
898        })
899        .collect()
900}
901
902fn validate_dependency_choices(choices: &[DependencyChoice]) -> anyhow::Result<()> {
903    if choices.len() > super::MAX_DEPENDENCY_SELECTIONS {
904        bail!(
905            "Plugin dependency selections exceed {} entries",
906            super::MAX_DEPENDENCY_SELECTIONS
907        );
908    }
909    let mut keys = std::collections::BTreeSet::new();
910    for choice in choices {
911        validate_existing_plugin_id(choice.consumer.plugin_id())?;
912        validate_instance_filename(choice.consumer.instance_key())?;
913        validate_requirement_id(&choice.requirement_id)?;
914        if let Some(provider) = &choice.provider {
915            validate_existing_plugin_id(provider.plugin_id())?;
916            validate_instance_filename(provider.instance_key())?;
917        }
918        if !keys.insert((choice.consumer.clone(), choice.requirement_id.clone())) {
919            bail!("duplicate dependency choice for `{}`", choice.consumer);
920        }
921    }
922    Ok(())
923}
924
925fn host_catalog_digest(root: &Path) -> anyhow::Result<String> {
926    let generated = root.join(HOST_BUILD);
927    let path = match fs::symlink_metadata(&generated) {
928        Ok(_) => generated,
929        Err(error) if error.kind() == std::io::ErrorKind::NotFound => root.join(HOST_CATALOG),
930        Err(error) => return Err(error).context("inspect generated Host authority source"),
931    };
932    let metadata = fs::symlink_metadata(&path).context("inspect Host Catalog source")?;
933    if !metadata.file_type().is_file() {
934        bail!(
935            "Host Catalog source must be a regular file: {}",
936            path.display()
937        );
938    }
939    Ok(sha256_digest(
940        &fs::read(path).context("read Host Catalog source")?,
941    ))
942}
943
944fn source_digests_for_changes(
945    root: &Path,
946    changes: &PluginRootChangeSet,
947) -> anyhow::Result<Vec<PluginRootSourceDigest>> {
948    let mut paths = changes
949        .configurations
950        .iter()
951        .map(|change| format!("{}/{}.toml", change.plugin_id, change.instance_key))
952        .collect::<Vec<_>>();
953    if changes.dependency_choices.is_some() {
954        paths.extend([
955            DEPENDENCY_SELECTIONS.to_owned(),
956            LEGACY_DEPENDENCY_SELECTIONS.to_owned(),
957        ]);
958    }
959    paths.sort();
960    paths.dedup();
961    paths
962        .into_iter()
963        .map(|path| root_source_digest(root, path))
964        .collect()
965}
966
967fn root_source_digest(root: &Path, path: String) -> anyhow::Result<PluginRootSourceDigest> {
968    let source = root.join(PLUGIN_ROOT).join(&path);
969    let bytes = match fs::symlink_metadata(&source) {
970        Ok(metadata) if metadata.file_type().is_file() => {
971            Some(fs::read(&source).with_context(|| format!("read {}", source.display()))?)
972        }
973        Ok(_) => bail!(
974            "Plugin Root source must be a regular file: {}",
975            source.display()
976        ),
977        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
978        Err(error) => return Err(error).with_context(|| format!("inspect {}", source.display())),
979    };
980    let mut digest = Sha256::new();
981    update_digest_component(&mut digest, ROOT_SOURCE_DIGEST_SCHEMA.as_bytes());
982    update_digest_component(&mut digest, path.as_bytes());
983    match bytes {
984        Some(bytes) => {
985            digest.update([1]);
986            update_digest_component(&mut digest, &bytes);
987        }
988        None => digest.update([0]),
989    }
990    Ok(PluginRootSourceDigest {
991        path,
992        digest: encode_sha256(digest.finalize()),
993    })
994}
995
996/// Builds review evidence without changing the Plugin Root.
997pub fn propose_instance_configuration(
998    root: &Path,
999    expected_revision: &PluginRootRevision,
1000    plugin_id: &str,
1001    instance: &str,
1002    bytes: &[u8],
1003) -> anyhow::Result<PluginConfigurationProposal> {
1004    validate_existing_plugin_id(plugin_id)?;
1005    validate_instance_filename(instance)?;
1006    let _lock = lock_plugin_root(root)?;
1007    let host = load_host_catalog(root)?;
1008    let current = snapshot_plugin_root(root, &host)?;
1009    let current_revision = revision_for_snapshot(&current)?;
1010    ensure_revision(expected_revision, &current_revision)?;
1011    let base_source_digest = source_digest_for_instance(root, plugin_id, instance)?;
1012    let root_proposal = build_root_change_proposal(
1013        root,
1014        &host,
1015        &current,
1016        current_revision.clone(),
1017        PluginRootChangeSet::new().with_configuration(PluginRootConfigurationChange::new(
1018            plugin_id,
1019            instance,
1020            bytes.to_vec(),
1021        )),
1022    )?;
1023    Ok(PluginConfigurationProposal {
1024        schema: PROPOSAL_SCHEMA,
1025        base_revision: current_revision,
1026        base_source_digest,
1027        candidate_revision: root_proposal.candidate_revision.clone(),
1028        digest: root_proposal.digest.clone(),
1029        status: root_proposal.status,
1030        application: root_proposal.application,
1031        diagnostics: root_proposal.diagnostics.clone(),
1032        plugin_id: plugin_id.to_owned(),
1033        instance_key: instance.to_owned(),
1034        toml: bytes.to_vec(),
1035        root_proposal,
1036    })
1037}
1038
1039/// Publishes an exact reviewed proposal when its base revision is still current.
1040pub fn publish_instance_configuration(
1041    root: &Path,
1042    proposal: &PluginConfigurationProposal,
1043) -> anyhow::Result<PluginConfigurationPublication> {
1044    if proposal.plugin_id != proposal.root_proposal.changes.configurations[0].plugin_id
1045        || proposal.instance_key != proposal.root_proposal.changes.configurations[0].instance_key
1046        || proposal.toml != proposal.root_proposal.changes.configurations[0].toml
1047        || proposal.digest != proposal.root_proposal.digest
1048    {
1049        bail!("Plugin configuration proposal no longer matches its reviewed candidate");
1050    }
1051    let current_revision = inspect_plugin_root(root)?.revision().clone();
1052    ensure_revision(&proposal.base_revision, &current_revision)?;
1053    let current_source_digest =
1054        source_digest_for_instance(root, &proposal.plugin_id, &proposal.instance_key)?;
1055    ensure_source_digest(&proposal.base_source_digest, &current_source_digest)?;
1056    let publication = publish_plugin_root_changes(root, &proposal.root_proposal)?;
1057    Ok(PluginConfigurationPublication {
1058        schema: PUBLICATION_SCHEMA,
1059        base_revision: proposal.base_revision.clone(),
1060        base_source_digest: proposal.base_source_digest.clone(),
1061        revision: publication.revision,
1062        proposal_digest: proposal.digest.clone(),
1063        resolved: publication.resolved,
1064    })
1065}
1066
1067fn classify_resolution(
1068    resolution: Result<ResolvedApp, PluginRootResolutionError>,
1069    candidate_revision: &PluginRootRevision,
1070    base_revision: &PluginRootRevision,
1071) -> (
1072    PluginConfigurationProposalStatus,
1073    PluginConfigurationApplication,
1074    Vec<PluginConfigurationDiagnostic>,
1075) {
1076    match resolution {
1077        Ok(_) => (
1078            PluginConfigurationProposalStatus::Ready,
1079            if candidate_revision == base_revision {
1080                PluginConfigurationApplication::Noop
1081            } else {
1082                PluginConfigurationApplication::AppGeneration
1083            },
1084            Vec::new(),
1085        ),
1086        Err(error) => {
1087            let status = if matches!(
1088                error,
1089                PluginRootResolutionError::AmbiguousSlot { .. }
1090                    | PluginRootResolutionError::AmbiguousCapability { .. }
1091            ) {
1092                PluginConfigurationProposalStatus::NeedsDecision
1093            } else {
1094                PluginConfigurationProposalStatus::Rejected
1095            };
1096            (
1097                status,
1098                PluginConfigurationApplication::Blocked,
1099                vec![PluginConfigurationDiagnostic {
1100                    code: resolution_error_code(&error),
1101                    detail: error.to_string(),
1102                }],
1103            )
1104        }
1105    }
1106}
1107
1108fn ensure_ready(
1109    status: PluginConfigurationProposalStatus,
1110    application: PluginConfigurationApplication,
1111    diagnostics: &[PluginConfigurationDiagnostic],
1112    subject: &str,
1113) -> anyhow::Result<()> {
1114    if status == PluginConfigurationProposalStatus::Ready
1115        && application != PluginConfigurationApplication::Blocked
1116    {
1117        return Ok(());
1118    }
1119    let detail = diagnostics
1120        .first()
1121        .map_or("candidate did not pass the Ready Gate", |diagnostic| {
1122            diagnostic.detail()
1123        });
1124    bail!("{subject} cannot be published: {detail}")
1125}
1126
1127fn resolution_error_code(error: &PluginRootResolutionError) -> &'static str {
1128    match error {
1129        PluginRootResolutionError::InvalidHostConfiguration(_) => "host_admission_denied",
1130        PluginRootResolutionError::AmbiguousSlot { .. } => "ambiguous_slot",
1131        PluginRootResolutionError::AmbiguousCapability { .. } => "ambiguous_capability",
1132        PluginRootResolutionError::InvalidConfiguration { .. } => "invalid_configuration",
1133        PluginRootResolutionError::MissingRequiredSlot(_) => "missing_required_slot",
1134        PluginRootResolutionError::MissingCapability { .. } => "missing_capability",
1135        PluginRootResolutionError::RequiredInstanceDisabled(_) => "required_instance_disabled",
1136        PluginRootResolutionError::UnknownPlugin(_) => "unknown_plugin",
1137        PluginRootResolutionError::UnknownDisabledInstance(_) => "unknown_disabled_instance",
1138        _ => "invalid_plugin_root",
1139    }
1140}
1141
1142fn parse_configuration(bytes: &[u8]) -> anyhow::Result<serde_json::Value> {
1143    let byte_count = u64::try_from(bytes.len()).context("Plugin configuration is too large")?;
1144    if byte_count > MAX_CONFIGURATION_BYTES {
1145        bail!("Plugin configuration exceeds 256 KiB");
1146    }
1147    let text = str::from_utf8(bytes).context("Plugin configuration must be UTF-8 TOML")?;
1148    let table: toml::Table = toml::from_str(text).context("parse Plugin configuration TOML")?;
1149    serde_json::to_value(table).context("convert Plugin configuration to portable values")
1150}
1151
1152pub(crate) fn ensure_revision(
1153    expected: &PluginRootRevision,
1154    current: &PluginRootRevision,
1155) -> anyhow::Result<()> {
1156    if expected == current {
1157        return Ok(());
1158    }
1159    Err(PluginRootRevisionConflict {
1160        expected: expected.clone(),
1161        current: current.clone(),
1162    }
1163    .into())
1164}
1165
1166/// A digest of the exact target configuration source, including absence.
1167#[derive(Clone, Debug, Eq, PartialEq)]
1168pub struct PluginConfigurationSourceDigest(String);
1169
1170impl PluginConfigurationSourceDigest {
1171    pub fn as_str(&self) -> &str {
1172        &self.0
1173    }
1174
1175    /// Digests one exact target configuration source or its explicit absence.
1176    pub fn for_source(
1177        plugin_id: &str,
1178        instance: &str,
1179        bytes: Option<&[u8]>,
1180    ) -> anyhow::Result<Self> {
1181        validate_existing_plugin_id(plugin_id)?;
1182        validate_instance_filename(instance)?;
1183        Ok(source_digest_for_bytes(plugin_id, instance, bytes))
1184    }
1185}
1186
1187impl fmt::Display for PluginConfigurationSourceDigest {
1188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1189        formatter.write_str(&self.0)
1190    }
1191}
1192
1193/// Exact configuration source changed after a proposal was reviewed.
1194#[derive(Clone, Debug, Eq, PartialEq)]
1195pub struct PluginConfigurationSourceConflict {
1196    expected: PluginConfigurationSourceDigest,
1197    current: PluginConfigurationSourceDigest,
1198}
1199
1200impl PluginConfigurationSourceConflict {
1201    pub const fn expected(&self) -> &PluginConfigurationSourceDigest {
1202        &self.expected
1203    }
1204
1205    pub const fn current(&self) -> &PluginConfigurationSourceDigest {
1206        &self.current
1207    }
1208}
1209
1210impl fmt::Display for PluginConfigurationSourceConflict {
1211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1212        write!(
1213            formatter,
1214            "Plugin configuration source conflict: expected {}, current {}",
1215            self.expected, self.current
1216        )
1217    }
1218}
1219
1220impl Error for PluginConfigurationSourceConflict {}
1221
1222pub(crate) fn source_digest_for_bytes(
1223    plugin_id: &str,
1224    instance: &str,
1225    bytes: Option<&[u8]>,
1226) -> PluginConfigurationSourceDigest {
1227    let mut authority = Sha256::new();
1228    update_digest_component(&mut authority, SOURCE_DIGEST_SCHEMA.as_bytes());
1229    update_digest_component(&mut authority, plugin_id.as_bytes());
1230    update_digest_component(&mut authority, instance.as_bytes());
1231    match bytes {
1232        Some(bytes) => {
1233            authority.update([1]);
1234            update_digest_component(&mut authority, bytes);
1235        }
1236        None => authority.update([0]),
1237    }
1238    PluginConfigurationSourceDigest(encode_sha256(authority.finalize()))
1239}
1240
1241fn source_digest_for_instance(
1242    root: &Path,
1243    plugin_id: &str,
1244    instance: &str,
1245) -> anyhow::Result<PluginConfigurationSourceDigest> {
1246    let path = root
1247        .join(PLUGIN_ROOT)
1248        .join(plugin_id)
1249        .join(format!("{instance}.toml"));
1250    let bytes = match fs::symlink_metadata(&path) {
1251        Ok(metadata) if metadata.file_type().is_file() => Some(
1252            fs::read(&path)
1253                .with_context(|| format!("read Plugin configuration source {}", path.display()))?,
1254        ),
1255        Ok(_) => bail!(
1256            "Plugin configuration source must be a regular file: {}",
1257            path.display()
1258        ),
1259        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1260        Err(error) => {
1261            return Err(error).with_context(|| {
1262                format!("inspect Plugin configuration source {}", path.display())
1263            });
1264        }
1265    };
1266    Ok(source_digest_for_bytes(
1267        plugin_id,
1268        instance,
1269        bytes.as_deref(),
1270    ))
1271}
1272
1273fn ensure_source_digest(
1274    expected: &PluginConfigurationSourceDigest,
1275    current: &PluginConfigurationSourceDigest,
1276) -> anyhow::Result<()> {
1277    if expected == current {
1278        return Ok(());
1279    }
1280    Err(PluginConfigurationSourceConflict {
1281        expected: expected.clone(),
1282        current: current.clone(),
1283    }
1284    .into())
1285}
1286
1287fn update_digest_component(authority: &mut Sha256, bytes: &[u8]) {
1288    authority.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
1289    authority.update(bytes);
1290}
1291
1292pub(crate) fn revision_for_snapshot(
1293    snapshot: &PluginRootSnapshot,
1294) -> anyhow::Result<PluginRootRevision> {
1295    let canonical =
1296        serde_json::to_vec(snapshot).context("encode Plugin Root revision authority")?;
1297    Ok(PluginRootRevision(sha256_digest(&canonical)))
1298}
1299
1300fn sha256_digest(bytes: &[u8]) -> String {
1301    encode_sha256(Sha256::digest(bytes))
1302}
1303
1304fn encode_sha256(digest: impl AsRef<[u8]>) -> String {
1305    let digest = digest.as_ref();
1306    let mut encoded = String::with_capacity(7 + digest.len() * 2);
1307    encoded.push_str("sha256:");
1308    for byte in digest {
1309        write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail");
1310    }
1311    encoded
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    use std::{fs, sync::Arc};
1317
1318    use lenso_app_plan::authoring::{
1319        HostDefaultPlugin, HostPluginRelease, HostSlot, PluginDescriptor,
1320    };
1321
1322    use super::*;
1323    use crate::{HOST_CATALOG, inspect_plugin_root};
1324
1325    fn fixture_root() -> tempfile::TempDir {
1326        let root = tempfile::tempdir().unwrap();
1327        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1328        let descriptor = PluginDescriptor::new("example.agent", "1.0.0", "agent")
1329            .with_configuration_schema(serde_json::json!({
1330                "type": "object",
1331                "properties": {
1332                    "greeting": { "type": "string" }
1333                },
1334                "additionalProperties": false
1335            }));
1336        let host = lenso_app_plan::authoring::HostCatalog::new(
1337            [HostSlot::one("agent")],
1338            [HostPluginRelease::new(descriptor)],
1339            [HostDefaultPlugin::new("example.agent", "default")],
1340        );
1341        fs::write(
1342            root.path().join(HOST_CATALOG),
1343            serde_json::to_vec(&host).unwrap(),
1344        )
1345        .unwrap();
1346        root
1347    }
1348
1349    fn coordinated_root() -> tempfile::TempDir {
1350        let root = tempfile::tempdir().unwrap();
1351        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1352        let schema = serde_json::json!({
1353            "type": "object",
1354            "properties": { "value": { "type": "string" } },
1355            "additionalProperties": false
1356        });
1357        let host = lenso_app_plan::authoring::HostCatalog::new(
1358            [HostSlot::one("source"), HostSlot::one("target")],
1359            [
1360                HostPluginRelease::new(
1361                    PluginDescriptor::new("example.source", "1.0.0", "source")
1362                        .with_configuration_schema(schema.clone()),
1363                ),
1364                HostPluginRelease::new(
1365                    PluginDescriptor::new("example.target", "1.0.0", "target")
1366                        .with_configuration_schema(schema),
1367                ),
1368            ],
1369            [
1370                HostDefaultPlugin::new("example.source", "default"),
1371                HostDefaultPlugin::new("example.target", "default"),
1372            ],
1373        );
1374        fs::write(
1375            root.path().join(HOST_CATALOG),
1376            serde_json::to_vec(&host).unwrap(),
1377        )
1378        .unwrap();
1379        root
1380    }
1381
1382    #[test]
1383    fn proposal_is_read_only_and_publication_advances_the_revision() {
1384        let root = fixture_root();
1385        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1386        let proposal = propose_instance_configuration(
1387            root.path(),
1388            &base,
1389            "example.agent",
1390            "default",
1391            b"greeting = \"hello\"\n",
1392        )
1393        .unwrap();
1394
1395        assert_eq!(proposal.status(), PluginConfigurationProposalStatus::Ready);
1396        assert_eq!(
1397            proposal.application(),
1398            PluginConfigurationApplication::AppGeneration
1399        );
1400        assert_eq!(proposal.base_revision(), &base);
1401        assert!(
1402            proposal
1403                .base_source_digest()
1404                .as_str()
1405                .starts_with("sha256:")
1406        );
1407        assert_ne!(proposal.candidate_revision(), &base);
1408        assert!(proposal.digest().starts_with("sha256:"));
1409        assert!(!configuration_path(root.path()).exists());
1410
1411        let publication = publish_instance_configuration(root.path(), &proposal).unwrap();
1412        assert_eq!(publication.base_revision(), &base);
1413        assert_eq!(
1414            publication.base_source_digest(),
1415            proposal.base_source_digest()
1416        );
1417        assert_eq!(publication.revision(), proposal.candidate_revision());
1418        assert_eq!(publication.proposal_digest(), proposal.digest());
1419        assert_eq!(
1420            fs::read_to_string(configuration_path(root.path())).unwrap(),
1421            "greeting = \"hello\"\n"
1422        );
1423    }
1424
1425    #[test]
1426    fn coordinated_proposal_publishes_two_configurations_and_choices_together() {
1427        let root = coordinated_root();
1428        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1429        let changes = PluginRootChangeSet::new()
1430            .with_configuration(PluginRootConfigurationChange::new(
1431                "example.source",
1432                "default",
1433                b"value = \"source\"\n".to_vec(),
1434            ))
1435            .with_configuration(PluginRootConfigurationChange::new(
1436                "example.target",
1437                "default",
1438                b"value = \"target\"\n".to_vec(),
1439            ))
1440            .with_dependency_choices([]);
1441        let proposal = propose_plugin_root_changes(root.path(), &base, changes).unwrap();
1442
1443        assert_eq!(proposal.status(), PluginConfigurationProposalStatus::Ready);
1444        assert_eq!(proposal.source_digests().len(), 4);
1445        assert!(proposal.host_catalog_digest().starts_with("sha256:"));
1446        assert!(
1447            !root
1448                .path()
1449                .join("plugins/example.source/default.toml")
1450                .exists()
1451        );
1452        assert!(!root.path().join("plugins/.dependencies.json").exists());
1453
1454        let publication = publish_plugin_root_changes(root.path(), &proposal).unwrap();
1455
1456        assert_eq!(publication.revision(), proposal.candidate_revision());
1457        assert_eq!(
1458            fs::read_to_string(root.path().join("plugins/example.source/default.toml")).unwrap(),
1459            "value = \"source\"\n"
1460        );
1461        assert_eq!(
1462            fs::read_to_string(root.path().join("plugins/example.target/default.toml")).unwrap(),
1463            "value = \"target\"\n"
1464        );
1465        let choices: DependencySelectionsDocument = serde_json::from_slice(
1466            &fs::read(root.path().join("plugins/.dependencies.json")).unwrap(),
1467        )
1468        .unwrap();
1469        assert_eq!(choices.schema_version, DEPENDENCY_SELECTIONS_SCHEMA_VERSION);
1470        assert!(choices.choices.is_empty());
1471    }
1472
1473    #[test]
1474    fn coordinated_publication_rejects_a_byte_changed_host_catalog() {
1475        let root = fixture_root();
1476        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1477        let changes =
1478            PluginRootChangeSet::new().with_configuration(PluginRootConfigurationChange::new(
1479                "example.agent",
1480                "default",
1481                b"greeting = \"hello\"\n".to_vec(),
1482            ));
1483        let proposal = propose_plugin_root_changes(root.path(), &base, changes).unwrap();
1484        let catalog: serde_json::Value =
1485            serde_json::from_slice(&fs::read(root.path().join(HOST_CATALOG)).unwrap()).unwrap();
1486        fs::write(
1487            root.path().join(HOST_CATALOG),
1488            serde_json::to_vec_pretty(&catalog).unwrap(),
1489        )
1490        .unwrap();
1491
1492        let error = publish_plugin_root_changes(root.path(), &proposal).unwrap_err();
1493
1494        assert!(error.to_string().contains("Host Catalog changed"));
1495        assert!(!configuration_path(root.path()).exists());
1496    }
1497
1498    #[test]
1499    fn requirement_migration_preserves_exact_provider_and_exposes_splits() {
1500        let consumer = PluginInstanceId::new("example.copy", "default");
1501        let provider = PluginInstanceId::new("example.store", "account-a");
1502        let current = [DependencyChoice {
1503            consumer: consumer.clone(),
1504            requirement_id: "~example.store@1".to_owned(),
1505            provider: Some(provider.clone()),
1506        }];
1507        let candidate = ["source", "archive"].map(|requirement_id| DependencyChoice {
1508            consumer: consumer.clone(),
1509            requirement_id: requirement_id.to_owned(),
1510            provider: Some(provider.clone()),
1511        });
1512
1513        let migrations = requirement_migrations(&current, &candidate);
1514
1515        assert_eq!(migrations.len(), 1);
1516        assert_eq!(migrations[0].provider(), Some(&provider));
1517        assert_eq!(
1518            migrations[0].new_requirement_ids(),
1519            &["archive".to_owned(), "source".to_owned()]
1520        );
1521        assert!(has_unreviewed_split(&migrations, &[]));
1522        assert!(!has_unreviewed_split(&migrations, &candidate));
1523    }
1524
1525    #[test]
1526    fn local_authority_dispatches_through_the_host_port() {
1527        let root = fixture_root();
1528        let authority: Arc<dyn PluginConfigurationAuthority> =
1529            Arc::new(LocalPluginRootAuthority::new(root.path()));
1530        let source = authority.source();
1531        let base = authority.inspect().unwrap().revision().clone();
1532
1533        let proposal = authority
1534            .propose(
1535                &base,
1536                "example.agent",
1537                "default",
1538                b"greeting = \"authority\"\n",
1539            )
1540            .unwrap();
1541        assert!(!configuration_path(root.path()).exists());
1542
1543        let publication = authority.publish(&proposal).unwrap();
1544
1545        assert_eq!(source.kind(), "local_plugin_root");
1546        assert_eq!(source.reference(), "app");
1547        assert_eq!(publication.revision(), proposal.candidate_revision());
1548        assert_eq!(
1549            authority.inspect().unwrap().revision(),
1550            publication.revision()
1551        );
1552    }
1553
1554    #[test]
1555    fn stale_publication_fails_with_a_typed_conflict_and_preserves_the_winner() {
1556        let root = fixture_root();
1557        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1558        let first = proposal(root.path(), &base, b"greeting = \"first\"\n");
1559        let stale = proposal(root.path(), &base, b"greeting = \"stale\"\n");
1560        let first_publication = publish_instance_configuration(root.path(), &first).unwrap();
1561
1562        let error = publish_instance_configuration(root.path(), &stale).unwrap_err();
1563        let conflict = error.downcast_ref::<PluginRootRevisionConflict>().unwrap();
1564
1565        assert_eq!(conflict.expected(), &base);
1566        assert_eq!(conflict.current(), first_publication.revision());
1567        assert_eq!(
1568            fs::read_to_string(configuration_path(root.path())).unwrap(),
1569            "greeting = \"first\"\n"
1570        );
1571    }
1572
1573    #[test]
1574    fn concurrent_publications_allow_exactly_one_winner() {
1575        let root = fixture_root();
1576        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1577        let first = proposal(root.path(), &base, b"greeting = \"first\"\n");
1578        let second = proposal(root.path(), &base, b"greeting = \"second\"\n");
1579        let path = root.path().to_path_buf();
1580        let barrier = Arc::new(std::sync::Barrier::new(3));
1581        let handles = [first, second].map(|proposal| {
1582            let path = path.clone();
1583            let barrier = Arc::clone(&barrier);
1584            std::thread::spawn(move || {
1585                barrier.wait();
1586                publish_instance_configuration(&path, &proposal)
1587            })
1588        });
1589        barrier.wait();
1590        let outcomes = handles.map(|handle| handle.join().unwrap());
1591
1592        assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1);
1593        let error = outcomes.into_iter().find_map(Result::err).unwrap();
1594        assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1595        let contents = fs::read_to_string(configuration_path(&path)).unwrap();
1596        assert!(contents == "greeting = \"first\"\n" || contents == "greeting = \"second\"\n");
1597    }
1598
1599    #[test]
1600    fn rejected_proposal_keeps_structured_diagnostics_without_writing() {
1601        let root = fixture_root();
1602        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1603        let proposal = proposal(root.path(), &base, b"unexpected = true\n");
1604
1605        assert_eq!(
1606            proposal.status(),
1607            PluginConfigurationProposalStatus::Rejected
1608        );
1609        assert_eq!(
1610            proposal.application(),
1611            PluginConfigurationApplication::Blocked
1612        );
1613        assert_eq!(proposal.diagnostics()[0].code(), "invalid_configuration");
1614        assert!(!configuration_path(root.path()).exists());
1615        assert!(publish_instance_configuration(root.path(), &proposal).is_err());
1616    }
1617
1618    #[test]
1619    fn plugin_root_revision_is_semantic_not_toml_formatting() {
1620        let root = fixture_root();
1621        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1622        let proposal = proposal(root.path(), &base, b"greeting = \"hello\"\n");
1623        let publication = publish_instance_configuration(root.path(), &proposal).unwrap();
1624        fs::write(
1625            configuration_path(root.path()),
1626            b"# human note\n\ngreeting=\"hello\"\n",
1627        )
1628        .unwrap();
1629
1630        let reformatted = inspect_plugin_root(root.path()).unwrap();
1631        assert_eq!(reformatted.revision(), publication.revision());
1632    }
1633
1634    #[test]
1635    fn formatting_only_source_change_rejects_stale_publication_without_overwrite() {
1636        let root = fixture_root();
1637        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1638        let initial = proposal(root.path(), &base, b"greeting = \"hello\"\n");
1639        let initial = publish_instance_configuration(root.path(), &initial).unwrap();
1640        let stale = proposal(root.path(), initial.revision(), b"greeting = \"goodbye\"\n");
1641        let external = b"# keep this human note\n\ngreeting=\"hello\"\n";
1642        fs::write(configuration_path(root.path()), external).unwrap();
1643
1644        assert_eq!(
1645            inspect_plugin_root(root.path()).unwrap().revision(),
1646            initial.revision()
1647        );
1648        let error = publish_instance_configuration(root.path(), &stale).unwrap_err();
1649        let conflict = error
1650            .downcast_ref::<PluginConfigurationSourceConflict>()
1651            .unwrap();
1652
1653        assert_eq!(conflict.expected(), stale.base_source_digest());
1654        assert_ne!(conflict.current(), stale.base_source_digest());
1655        assert_eq!(fs::read(configuration_path(root.path())).unwrap(), external);
1656    }
1657
1658    #[test]
1659    fn proposal_digest_closes_the_exact_reviewed_toml() {
1660        let root = fixture_root();
1661        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1662        let compact = propose_instance_configuration(
1663            root.path(),
1664            &base,
1665            "example.agent",
1666            "default",
1667            b"greeting=\"hello\"\n",
1668        )
1669        .unwrap();
1670        let formatted = propose_instance_configuration(
1671            root.path(),
1672            &base,
1673            "example.agent",
1674            "default",
1675            b"greeting = \"hello\"\n",
1676        )
1677        .unwrap();
1678
1679        assert_eq!(compact.candidate_revision(), formatted.candidate_revision());
1680        assert_ne!(compact.digest(), formatted.digest());
1681    }
1682
1683    #[test]
1684    fn source_digest_domains_raw_bytes_absence_and_instance_identity() {
1685        let absent =
1686            PluginConfigurationSourceDigest::for_source("example.agent", "default", None).unwrap();
1687        let empty =
1688            PluginConfigurationSourceDigest::for_source("example.agent", "default", Some(b""))
1689                .unwrap();
1690        let other_instance =
1691            PluginConfigurationSourceDigest::for_source("example.agent", "secondary", None)
1692                .unwrap();
1693
1694        assert_ne!(absent, empty);
1695        assert_ne!(absent, other_instance);
1696    }
1697
1698    #[test]
1699    fn plugin_root_revision_round_trips_for_http_preconditions() {
1700        let root = fixture_root();
1701        let revision = inspect_plugin_root(root.path()).unwrap().revision().clone();
1702
1703        assert_eq!(
1704            revision.as_str().parse::<PluginRootRevision>().unwrap(),
1705            revision
1706        );
1707        assert!("sha256:not-a-digest".parse::<PluginRootRevision>().is_err());
1708    }
1709
1710    fn proposal(
1711        root: &Path,
1712        base: &PluginRootRevision,
1713        toml: &[u8],
1714    ) -> PluginConfigurationProposal {
1715        propose_instance_configuration(root, base, "example.agent", "default", toml).unwrap()
1716    }
1717
1718    fn configuration_path(root: &Path) -> std::path::PathBuf {
1719        root.join("plugins/example.agent/default.toml")
1720    }
1721}