Skip to main content

soma_application/
provider_registry.rs

1//! Provider dispatch registry: the [`ProviderRegistry`] resolves actions to
2//! providers, builds catalog snapshots, and indexes resources and prompts.
3use std::{
4    collections::{BTreeMap, HashMap},
5    sync::{Arc, RwLock},
6};
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Map, Value};
11use soma_domain::provider_validation::{validate_provider_manifest, ProviderValidationError};
12use soma_provider_core::{
13    ProviderCall as CoreProviderCall, ProviderCatalog, ProviderRegistry as CoreRegistry,
14    ProviderResource, RegistrySnapshot as CoreRegistrySnapshot,
15};
16
17use crate::{
18    capabilities::CapabilityBroker, provider_errors::ProviderError,
19    providers::filesystem::FileProviderSource,
20};
21
22mod enforcement;
23mod refresh;
24mod reports;
25mod resources;
26pub(super) use enforcement::provider_tool_surface_enabled;
27use enforcement::{enforce_capabilities, enforce_pre_input, enforce_response_limit};
28use refresh::ProviderRefreshEvent;
29use resources::ResourceIndex;
30pub use resources::{DynamicResourceTemplate, ResourceReadOutput};
31pub use soma_provider_core::{Provider as CoreProvider, ProviderOutput};
32/// Product-neutral provider call shape (`soma_provider_core::ProviderCall`),
33/// as handed to the shared core registry once auth/scope fields are stripped.
34pub type ProviderInvocation = CoreProviderCall;
35
36/// A loaded provider: exposes a catalog and dispatches action calls, with
37/// optional MCP resource-read support layered on top.
38#[async_trait]
39pub trait Provider: Send + Sync {
40    /// The provider's catalog — its tools, prompts, resources, and metadata.
41    fn catalog(&self) -> ProviderCatalog;
42
43    /// Executes one provider action call and returns its output.
44    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError>;
45
46    /// Dynamic resource templates this provider serves. Every provider
47    /// inherits the empty default — only file-based dynamic resource
48    /// readers (`providers/resources/*.ts`) override it.
49    fn dynamic_resource_templates(&self) -> Vec<DynamicResourceTemplate> {
50        Vec::new()
51    }
52
53    /// Whether this provider can actually serve `read_resource` calls.
54    /// `catalog().resources` is a schema-legal field on every provider
55    /// kind's manifest (OpenAPI, MCP, ai-sdk, WASM, Python, generic JSON),
56    /// but only file-based `ResourceFileProvider`s have any mechanism to
57    /// read content back — every other kind inherits `read_resource`'s
58    /// default error. `ResourceIndex::register` uses this to reject a
59    /// manifest that declares resources it can never serve at snapshot-build
60    /// time, rather than letting them appear in `resources/list` and always
61    /// fail `resources/read` with an opaque error.
62    fn supports_resource_reads(&self) -> bool {
63        false
64    }
65
66    /// Reads resource content for a URI the registry has already matched
67    /// against either this provider's `catalog().resources` (exact,
68    /// `params` empty) or one of its `dynamic_resource_templates()`
69    /// (`params` holds captured path parameters).
70    async fn read_resource(
71        &self,
72        uri: &str,
73        params: &BTreeMap<String, String>,
74    ) -> Result<ResourceReadOutput, ProviderError> {
75        let _ = params;
76        Err(ProviderError::validation(
77            &self.catalog().provider.name,
78            uri,
79            "resource_read_not_supported",
80            "this provider does not support resource reads",
81        ))
82    }
83}
84
85/// The Soma-facing surface a provider call arrives on.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum ProviderSurface {
89    /// MCP tool/resource/prompt surface.
90    Mcp,
91    /// REST HTTP surface.
92    Rest,
93    /// Command-line surface.
94    Cli,
95    /// Command palette surface.
96    Palette,
97}
98
99impl ProviderSurface {
100    /// The lowercase string identifier for this surface.
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Self::Mcp => "mcp",
104            Self::Rest => "rest",
105            Self::Cli => "cli",
106            Self::Palette => "palette",
107        }
108    }
109
110    fn core(self) -> soma_provider_core::ProviderSurface {
111        match self {
112            Self::Mcp => soma_provider_core::ProviderSurface::Mcp,
113            Self::Rest => soma_provider_core::ProviderSurface::Rest,
114            Self::Cli => soma_provider_core::ProviderSurface::Cli,
115            Self::Palette => soma_provider_core::ProviderSurface::Palette,
116        }
117    }
118}
119
120/// How the caller authenticated, governing whether scope checks apply.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum ProviderAuthMode {
123    /// Loopback development mode; scope checks bypassed.
124    LoopbackDev,
125    /// Non-loopback behind an authz-enforcing gateway; scope checks bypassed.
126    TrustedGateway,
127    /// Mounted auth middleware; scope checks enforced.
128    Mounted,
129}
130
131/// The authenticated caller: its subject identity and granted scopes.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct ProviderPrincipal {
134    /// Subject identifier for the caller.
135    pub subject: String,
136    /// Scopes granted to the caller.
137    pub scopes: Vec<String>,
138}
139
140impl ProviderPrincipal {
141    /// The synthetic principal used in loopback development mode, granted the
142    /// read scope.
143    pub fn loopback_dev() -> Self {
144        Self {
145            subject: "loopback-dev".to_owned(),
146            scopes: vec![soma_domain::actions::READ_SCOPE.to_owned()],
147        }
148    }
149
150    /// An anonymous principal with no scopes.
151    pub fn anonymous() -> Self {
152        Self {
153            subject: "anonymous".to_owned(),
154            scopes: Vec::new(),
155        }
156    }
157}
158
159/// Byte-size ceilings enforced on a provider call's input and response.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct ProviderRequestLimits {
162    /// Maximum accepted request input size in bytes.
163    pub max_input_bytes: usize,
164    /// Maximum permitted response size in bytes.
165    pub max_response_bytes: usize,
166}
167
168impl Default for ProviderRequestLimits {
169    fn default() -> Self {
170        Self {
171            max_input_bytes: 64 * 1024,
172            max_response_bytes: soma_domain::token_limit::MAX_RESPONSE_BYTES,
173        }
174    }
175}
176
177/// A fully-resolved provider action invocation, carrying auth, scope, surface,
178/// and limit context alongside the action and its parameters.
179#[derive(Debug, Clone)]
180pub struct ProviderCall {
181    /// Target provider name.
182    pub provider: String,
183    /// Action to invoke on the provider.
184    pub action: String,
185    /// Action parameters as JSON.
186    pub params: Value,
187    /// Authenticated caller.
188    pub principal: ProviderPrincipal,
189    /// Authentication mode governing scope enforcement.
190    pub auth_mode: ProviderAuthMode,
191    /// Surface the call arrived on.
192    pub surface: ProviderSurface,
193    /// Whether the caller confirmed a destructive action.
194    pub destructive_confirmed: bool,
195    /// Byte-size limits applied to this call.
196    pub limits: ProviderRequestLimits,
197    /// Registry snapshot id this call is bound to.
198    pub snapshot_id: String,
199}
200
201impl ProviderCall {
202    /// Strips this call down to the product-neutral core invocation shape.
203    pub fn provider_invocation(&self) -> ProviderInvocation {
204        ProviderInvocation {
205            provider: self.provider.clone(),
206            action: self.action.clone(),
207            params: self.params.clone(),
208            surface: self.surface.core(),
209            snapshot_id: self.snapshot_id.clone(),
210        }
211    }
212
213    /// Builds the serializable execution envelope describing this call.
214    pub fn execution_envelope(&self) -> ProviderExecutionEnvelope {
215        ProviderExecutionEnvelope {
216            schema_version: 1,
217            provider: self.provider.clone(),
218            action: self.action.clone(),
219            params: self.params.clone(),
220            surface: self.surface,
221            snapshot_id: self.snapshot_id.clone(),
222        }
223    }
224
225    /// Serializes the execution envelope to JSON bytes.
226    pub fn execution_payload(&self) -> Result<Vec<u8>, serde_json::Error> {
227        serde_json::to_vec(&self.execution_envelope())
228    }
229}
230
231/// The serializable record of a provider action invocation, emitted for
232/// downstream execution/audit consumers.
233#[derive(Debug, Clone, Serialize)]
234pub struct ProviderExecutionEnvelope {
235    /// Envelope schema version.
236    pub schema_version: u32,
237    /// Target provider name.
238    pub provider: String,
239    /// Action invoked.
240    pub action: String,
241    /// Action parameters as JSON.
242    pub params: Value,
243    /// Surface the call arrived on.
244    pub surface: ProviderSurface,
245    /// Registry snapshot id the call is bound to.
246    pub snapshot_id: String,
247}
248
249/// An immutable, fingerprinted view of the loaded providers: their catalogs,
250/// routing/resource indexes, and cached derived artifacts.
251#[derive(Clone)]
252pub struct RegistrySnapshot {
253    /// Snapshot identifier (equal to the fingerprint).
254    pub id: String,
255    /// Content fingerprint of the loaded provider set.
256    pub fingerprint: String,
257    /// Catalogs for every loaded provider.
258    pub catalogs: Vec<ProviderCatalog>,
259    core: Arc<CoreRegistrySnapshot>,
260    exact_resources: HashMap<String, (String, ProviderResource)>,
261    dynamic_resources: Vec<(String, DynamicResourceTemplate)>,
262    /// Number of JSON schema validators compiled for this snapshot.
263    pub compiled_validator_count: usize,
264    /// Cached, pre-serialized OpenAPI document bytes.
265    pub cached_openapi_bytes: Arc<Vec<u8>>,
266    /// Cached catalog summary JSON.
267    pub cached_catalog_summary: Arc<Value>,
268}
269
270impl RegistrySnapshot {
271    /// The names of every action in this snapshot.
272    pub fn action_names(&self) -> Vec<&str> {
273        self.core.action_names().collect()
274    }
275
276    /// Resolves a REST method+path to its action name, if routed.
277    pub fn route_action(&self, method: &str, path: &str) -> Option<&str> {
278        self.core.route_action(method, path)
279    }
280
281    /// Resolves a CLI command to its action name, if routed.
282    pub fn cli_action(&self, command: &str) -> Option<&str> {
283        self.core.cli_action(command)
284    }
285
286    /// The primitive kind (tool/prompt/resource) for a named primitive.
287    pub fn primitive_kind(&self, name: &str) -> Option<&str> {
288        self.core.primitive_kind(name)
289    }
290
291    /// Whether the action is marked destructive and requires confirmation.
292    pub fn action_requires_confirmation(&self, action: &str) -> bool {
293        self.core
294            .tool(action)
295            .map(|entry| entry.spec().destructive)
296            .unwrap_or(false)
297    }
298
299    /// The provider that owns the given action.
300    pub fn provider_for_action(&self, action: &str) -> Option<&str> {
301        self.core
302            .tool(action)
303            .map(|entry| entry.provider_id().as_str())
304    }
305
306    /// Borrows the underlying product-neutral core snapshot.
307    pub fn core_snapshot(&self) -> &CoreRegistrySnapshot {
308        &self.core
309    }
310
311    pub(crate) fn rest_routes(&self) -> impl Iterator<Item = (&str, &str, &str)> {
312        self.core.rest_routes()
313    }
314}
315
316/// Thread-safe registry of loaded providers backing every Soma surface,
317/// holding the active snapshot and supporting hot-reload from a file source.
318#[derive(Clone)]
319pub struct ProviderRegistry {
320    state: Arc<RwLock<RegistryState>>,
321    capabilities: CapabilityBroker,
322    base_providers: Arc<Vec<Arc<dyn Provider>>>,
323    file_source: Option<FileProviderSource>,
324}
325
326struct RegistryState {
327    providers: BTreeMap<String, Arc<dyn Provider>>,
328    core_registry: CoreRegistry,
329    snapshot: Arc<RegistrySnapshot>,
330    file_fingerprint: Option<String>,
331}
332
333impl ProviderRegistry {
334    /// Builds a registry from the given providers with a default-deny
335    /// capability broker.
336    pub fn new(providers: Vec<Arc<dyn Provider>>) -> Result<Self, ProviderValidationError> {
337        Self::with_capabilities(providers, CapabilityBroker::default_deny())
338    }
339
340    /// Builds a registry from the given providers and capability broker.
341    pub fn with_capabilities(
342        providers: Vec<Arc<dyn Provider>>,
343        capabilities: CapabilityBroker,
344    ) -> Result<Self, ProviderValidationError> {
345        let (providers, core_registry, snapshot) = build_registry(providers)?;
346        Ok(Self {
347            state: Arc::new(RwLock::new(RegistryState {
348                providers,
349                core_registry,
350                snapshot,
351                file_fingerprint: None,
352            })),
353            capabilities,
354            base_providers: Arc::new(Vec::new()),
355            file_source: None,
356        })
357    }
358
359    /// Builds a registry from the given base providers plus dynamic providers
360    /// loaded from a file source, enabling later hot-reloads.
361    pub fn with_file_source(
362        providers: Vec<Arc<dyn Provider>>,
363        capabilities: CapabilityBroker,
364        file_source: FileProviderSource,
365    ) -> Result<Self, ProviderValidationError> {
366        let file_fingerprint = file_source.fingerprint().map_err(|error| {
367            ProviderValidationError::new("provider_file_load_failed", error.to_string())
368        })?;
369        let dynamic_providers = file_source.load().map_err(|error| {
370            ProviderValidationError::new("provider_file_load_failed", error.to_string())
371        })?;
372        let base_providers = Arc::new(providers);
373        let mut all_providers = base_providers.iter().cloned().collect::<Vec<_>>();
374        all_providers.extend(dynamic_providers);
375        let (providers, core_registry, snapshot) = build_registry(all_providers)?;
376        Ok(Self {
377            state: Arc::new(RwLock::new(RegistryState {
378                providers,
379                core_registry,
380                snapshot,
381                file_fingerprint: Some(file_fingerprint),
382            })),
383            capabilities,
384            base_providers,
385            file_source: Some(file_source),
386        })
387    }
388
389    /// Returns the currently active registry snapshot.
390    pub fn snapshot(&self) -> Arc<RegistrySnapshot> {
391        self.state
392            .read()
393            .expect("provider registry lock should not be poisoned")
394            .snapshot
395            .clone()
396    }
397
398    /// Refreshes providers from the file source, if any. Per the drop-in
399    /// provider layout contract ("If a resource disappears or becomes
400    /// invalid, a reload must leave the last valid snapshot active until a
401    /// valid replacement snapshot is available"), a failure anywhere in this
402    /// pipeline (an unreadable directory, a newly-invalid or colliding
403    /// provider file) is logged and this returns the previous snapshot
404    /// rather than propagating the error — one bad drop-in file must not
405    /// take down `list_tools`/`list_prompts`/`read_resource`/etc. for every
406    /// other, unrelated, already-loaded provider.
407    pub fn refresh_file_providers(&self) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
408        let Some(file_source) = &self.file_source else {
409            return Ok(self.snapshot());
410        };
411        let file_fingerprint = match file_source.fingerprint() {
412            Ok(fingerprint) => fingerprint,
413            Err(error) => {
414                return Ok(self.snapshot_after_refresh_failure(
415                    file_source,
416                    "provider_file_fingerprint_failed",
417                    &error.to_string(),
418                ));
419            }
420        };
421        {
422            let state = self
423                .state
424                .read()
425                .expect("provider registry lock should not be poisoned");
426            if state.file_fingerprint.as_deref() == Some(file_fingerprint.as_str()) {
427                return Ok(state.snapshot.clone());
428            }
429        }
430
431        let rebuilt: Result<_, ProviderValidationError> = (|| {
432            let dynamic_providers = file_source.load().map_err(|error| {
433                ProviderValidationError::new("provider_file_load_failed", error.to_string())
434            })?;
435            let mut providers = self.base_providers.iter().cloned().collect::<Vec<_>>();
436            providers.extend(dynamic_providers);
437            let (providers, core_registry, snapshot) = build_registry(providers)?;
438            Ok((providers, core_registry, snapshot))
439        })();
440        let (providers, core_registry, snapshot) = match rebuilt {
441            Ok(parts) => parts,
442            Err(error) => {
443                return Ok(self.snapshot_after_refresh_failure(
444                    file_source,
445                    error.code(),
446                    error.message(),
447                ));
448            }
449        };
450
451        let mut state = self
452            .state
453            .write()
454            .expect("provider registry lock should not be poisoned");
455        if state.snapshot.fingerprint == snapshot.fingerprint {
456            return Ok(state.snapshot.clone());
457        }
458        let previous = state.snapshot.clone();
459        let event = ProviderRefreshEvent::new(&previous, &snapshot);
460        state.providers = providers;
461        state.core_registry = core_registry;
462        state.snapshot = snapshot.clone();
463        state.file_fingerprint = Some(file_fingerprint);
464        event.log(file_source.root());
465        Ok(snapshot)
466    }
467
468    fn snapshot_after_refresh_failure(
469        &self,
470        file_source: &FileProviderSource,
471        code: &str,
472        message: &str,
473    ) -> Arc<RegistrySnapshot> {
474        tracing::warn!(
475            root = %file_source.root().display(),
476            code,
477            message,
478            "provider directory refresh failed; keeping the last valid snapshot active"
479        );
480        self.snapshot()
481    }
482
483    /// Builds and validates a snapshot from the given providers without
484    /// installing it, for pre-flight checking a candidate reload.
485    pub fn validate_reload(
486        &self,
487        providers: Vec<Arc<dyn Provider>>,
488    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
489        let (_, _, snapshot) = build_registry(providers)?;
490        Ok(snapshot)
491    }
492
493    /// Atomically replaces the loaded providers and active snapshot,
494    /// clearing any file fingerprint.
495    pub fn reload(
496        &self,
497        providers: Vec<Arc<dyn Provider>>,
498    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
499        let (providers, core_registry, snapshot) = build_registry(providers)?;
500        let mut state = self
501            .state
502            .write()
503            .expect("provider registry lock should not be poisoned");
504        state.providers = providers;
505        state.core_registry = core_registry;
506        state.snapshot = snapshot.clone();
507        state.file_fingerprint = None;
508        Ok(snapshot)
509    }
510
511    /// Routes a provider call to its owning provider, enforcing pre-input
512    /// checks, declared capabilities, and the response size limit around the
513    /// provider's own `call`.
514    pub async fn dispatch(&self, mut call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
515        let (snapshot, core_registry, provider, tool, capabilities, provider_kind) = {
516            let state = self
517                .state
518                .read()
519                .expect("provider registry lock should not be poisoned");
520            let snapshot = state.snapshot.clone();
521            let entry = snapshot.core_snapshot().tool(&call.action).ok_or_else(|| {
522                ProviderError::validation(
523                    "registry",
524                    call.action.clone(),
525                    "unknown_action",
526                    format!("unknown provider action `{}`", call.action),
527                )
528            })?;
529            let provider_name = entry.provider_id().as_str();
530            let tool = entry.spec().clone();
531            let provider = state.providers.get(provider_name).cloned().ok_or_else(|| {
532                ProviderError::new(
533                    "provider_not_loaded",
534                    provider_name,
535                    Some(entry.spec().name.clone()),
536                    "provider is not loaded in the active registry",
537                    "Reload providers and retry.",
538                )
539            })?;
540            let (capabilities, provider_kind) = snapshot
541                .catalogs
542                .iter()
543                .find(|catalog| catalog.provider.name == provider_name)
544                .map(|catalog| (catalog.capabilities.clone(), catalog.provider.kind))
545                .expect("core provider index must reference an active catalog");
546            (
547                snapshot,
548                state.core_registry.clone(),
549                provider,
550                tool,
551                capabilities,
552                provider_kind,
553            )
554        };
555
556        call.provider = provider.catalog().provider.name;
557        call.snapshot_id = snapshot.id.clone();
558        let pre_input_call = call.clone();
559        let invocation_call = call.clone();
560        let pre_input_tool = tool.clone();
561        let capability_broker = self.capabilities.clone();
562        core_registry
563            .dispatch_with_pre_input(
564                call.provider_invocation(),
565                move |invocation| {
566                    let mut call = pre_input_call;
567                    call.provider.clone_from(&invocation.provider);
568                    call.snapshot_id.clone_from(&invocation.snapshot_id);
569                    enforce_pre_input(&pre_input_tool, &call, provider_kind)
570                },
571                move |_, invocation| {
572                    let mut call = invocation_call;
573                    call.provider = invocation.provider;
574                    call.snapshot_id = invocation.snapshot_id;
575                    async move {
576                        enforce_capabilities(&capabilities, &call, &capability_broker)?;
577                        let output = provider.call(call.clone()).await?;
578                        enforce_response_limit(&tool, &call, &output)?;
579                        Ok(output)
580                    }
581                },
582            )
583            .await
584            .inspect_err(|error| {
585                let (provider, action, code) = error.log_code();
586                tracing::warn!(provider, action, code, "provider call failed");
587            })
588    }
589}
590
591fn provider_map(
592    providers: Vec<Arc<dyn Provider>>,
593) -> Result<BTreeMap<String, Arc<dyn Provider>>, ProviderValidationError> {
594    let mut map = BTreeMap::new();
595    for provider in providers {
596        let name = provider.catalog().provider.name;
597        if map.insert(name.clone(), provider).is_some() {
598            return Err(ProviderValidationError::new(
599                "duplicate_provider_name",
600                format!("duplicate provider `{name}`"),
601            ));
602        }
603    }
604    Ok(map)
605}
606
607/// Wraps a product-neutral `soma_provider_core::Provider` (as implemented by
608/// every adapter in `soma-provider-adapters`) so it satisfies this crate's
609/// own `Provider` trait, which carries additional auth/scope fields
610/// (`principal`, `auth_mode`, `destructive_confirmed`, `limits`) that no
611/// shared adapter reads — see `ProviderCall::provider_invocation()`, which
612/// this reuses to build the generic call. This is the mirror image of
613/// `CoreProviderAdapter` below, which wraps the other direction.
614#[derive(Clone)]
615pub struct SharedAdapter(Arc<dyn soma_provider_core::Provider>);
616
617impl SharedAdapter {
618    /// Wraps a shared core provider so it satisfies this crate's `Provider`
619    /// trait.
620    pub fn wrap(inner: Arc<dyn soma_provider_core::Provider>) -> Arc<dyn Provider> {
621        Arc::new(Self(inner))
622    }
623}
624
625#[async_trait]
626impl Provider for SharedAdapter {
627    fn catalog(&self) -> ProviderCatalog {
628        self.0.catalog()
629    }
630
631    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
632        self.0.call(call.provider_invocation()).await
633    }
634}
635
636#[derive(Clone)]
637struct CoreProviderAdapter(Arc<dyn Provider>);
638
639type BuiltRegistry = (
640    BTreeMap<String, Arc<dyn Provider>>,
641    CoreRegistry,
642    Arc<RegistrySnapshot>,
643);
644
645#[async_trait]
646impl CoreProvider for CoreProviderAdapter {
647    fn catalog(&self) -> ProviderCatalog {
648        self.0.catalog()
649    }
650
651    async fn call(&self, call: CoreProviderCall) -> Result<ProviderOutput, ProviderError> {
652        let surface = match call.surface {
653            soma_provider_core::ProviderSurface::Mcp => ProviderSurface::Mcp,
654            soma_provider_core::ProviderSurface::Rest => ProviderSurface::Rest,
655            soma_provider_core::ProviderSurface::Cli => ProviderSurface::Cli,
656            soma_provider_core::ProviderSurface::Palette => ProviderSurface::Palette,
657            soma_provider_core::ProviderSurface::Internal
658            | soma_provider_core::ProviderSurface::Ui => {
659                return Err(ProviderError::validation(
660                    call.provider,
661                    call.action,
662                    "unsupported_product_surface",
663                    "Soma providers expose only MCP, REST, CLI, and Palette surfaces",
664                ));
665            }
666        };
667        self.0
668            .call(ProviderCall {
669                provider: call.provider,
670                action: call.action,
671                params: call.params,
672                principal: ProviderPrincipal::anonymous(),
673                auth_mode: ProviderAuthMode::TrustedGateway,
674                surface,
675                destructive_confirmed: false,
676                limits: ProviderRequestLimits::default(),
677                snapshot_id: call.snapshot_id,
678            })
679            .await
680    }
681}
682
683fn build_registry(
684    providers: Vec<Arc<dyn Provider>>,
685) -> Result<BuiltRegistry, ProviderValidationError> {
686    let providers = provider_map(providers)?;
687    let mut builder = CoreRegistry::builder();
688    for provider in providers.values() {
689        validate_provider_manifest(&provider.catalog())?;
690        builder = builder.register_arc(Arc::new(CoreProviderAdapter(provider.clone())))?;
691    }
692    let core_registry = builder.build()?;
693    let snapshot = Arc::new(build_snapshot(&providers, &core_registry)?);
694    Ok((providers, core_registry, snapshot))
695}
696
697fn build_snapshot(
698    providers: &BTreeMap<String, Arc<dyn Provider>>,
699    core_registry: &CoreRegistry,
700) -> Result<RegistrySnapshot, ProviderValidationError> {
701    let core = core_registry.snapshot();
702    let mut resources = ResourceIndex::new();
703    for provider in providers.values() {
704        let catalog = provider.catalog();
705        resources.register(&**provider, &catalog.provider.name, &catalog.resources)?;
706    }
707    let catalogs = core.catalogs().to_vec();
708    let fingerprint = core.fingerprint().to_string();
709    let id = fingerprint.clone();
710    let action_names = core.action_names().map(str::to_owned).collect::<Vec<_>>();
711    let openapi_paths = openapi_paths_from_core(&core);
712    let cached_catalog_summary = Arc::new(json!({
713        "schema_version": 1,
714        "provider_fingerprint": fingerprint,
715        "actions": action_names,
716    }));
717    let cached_openapi_bytes = Arc::new(
718        serde_json::to_vec_pretty(&json!({
719            "openapi": "3.1.0",
720            "info": {"title": "soma provider API", "version": env!("CARGO_PKG_VERSION")},
721            "x-soma": {
722                "preferred_rest_style": "direct_routes",
723                "provider_fingerprint": fingerprint
724            },
725            "paths": openapi_paths
726        }))
727        .expect("static OpenAPI summary serializes"),
728    );
729
730    Ok(RegistrySnapshot {
731        id,
732        fingerprint,
733        catalogs,
734        core: core.clone(),
735        exact_resources: resources.exact,
736        dynamic_resources: resources.dynamic,
737        compiled_validator_count: core.compiled_validator_count(),
738        cached_openapi_bytes,
739        cached_catalog_summary,
740    })
741}
742
743fn openapi_paths_from_core(core: &CoreRegistrySnapshot) -> Value {
744    let mut paths = Map::new();
745    paths.insert(
746        "/v1/capabilities".to_owned(),
747        json!({
748            "get": {
749                "summary": "List REST capabilities",
750                "operationId": "v1Capabilities",
751                "responses": {
752                    "200": {"description": "Route inventory and server metadata"}
753                }
754            }
755        }),
756    );
757    paths.insert(
758        "/v1/providers".to_owned(),
759        json!({
760            "get": {
761                "summary": "Inspect live providers",
762                "operationId": "v1Providers",
763                "responses": {
764                    "200": {"description": "Live provider catalog and runtime inventory"}
765                }
766            }
767        }),
768    );
769    paths.insert(
770        "/v1/tools/{action}".to_owned(),
771        json!({
772            "post": {
773                "summary": "Run a provider tool",
774                "operationId": "runProviderTool",
775                "parameters": [{
776                    "name": "action",
777                    "in": "path",
778                    "required": true,
779                    "schema": {"type": "string"},
780                    "description": "Provider tool action name"
781                }],
782                "requestBody": {
783                    "required": false,
784                    "content": {
785                        "application/json": {
786                            "schema": {"type": "object", "additionalProperties": true}
787                        }
788                    }
789                },
790                "responses": {
791                    "200": {"description": "Provider action response"},
792                    "400": {"description": "Provider validation error"},
793                    "403": {"description": "Provider authorization error"},
794                    "404": {"description": "Unknown action or surface not exposed"}
795                }
796            }
797        }),
798    );
799
800    let mut routes = core
801        .rest_routes()
802        .map(|(method, path, action)| (method.to_owned(), path.to_owned(), action.to_owned()))
803        .collect::<Vec<_>>();
804    routes.sort_by(|left, right| left.1.cmp(&right.1).then(left.0.cmp(&right.0)));
805
806    for (method, path, action) in routes {
807        let entry = paths
808            .entry(path)
809            .or_insert_with(|| Value::Object(Map::new()));
810        if let Value::Object(methods) = entry {
811            methods.insert(
812                method.to_ascii_lowercase(),
813                json!({
814                    "summary": format!("Provider action `{action}`"),
815                    "operationId": action,
816                    "responses": {
817                        "200": {"description": "Provider action response"},
818                        "400": {"description": "Provider validation error"}
819                    }
820                }),
821            );
822        }
823    }
824    Value::Object(paths)
825}