Skip to main content

soma_som_core/
sibling.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2#![allow(missing_docs)]
3
4//! Sibling extension manifest — the structural contract for ring-hosted siblings.
5//!
6//! ## Sibling extension mechanism
7//!
8//! Siblings are ring-native applications that run on the host ring application through the ring.
9//! Each sibling declares its identity, ring extensions, resource requirements,
10//! and structural routes via a `SiblingManifest` implementation.
11//!
12//! ## DOR-SIBLINGS-F1 / SIBLING-INTEGRATION-SPEC §2.2
13//!
14//! The manifest uses typed factory methods (not declarative declarations)
15//! matching the existing ring extension registration API:
16//! - `through_handler()` → `Arc<dyn ThroughRing>` (shared ownership for dispatch)
17//! - `before_gate()` → `Box<dyn BeforeRing>` (exclusive ownership)
18//! - `after_observer()` → `Box<dyn AfterRing>` (exclusive ownership)
19//! - `around_extension()` → `Box<dyn AroundRing>` (exclusive ownership)
20//!
21//! ## Spec traceability
22//!
23//! | Item                  | Spec Reference                    |
24//! |-----------------------|-----------------------------------|
25//! | `SiblingManifest`     | DOR-SIBLINGS-F1 D3, SIBLING-INTEGRATION-SPEC §2.2 |
26//! | `DomainRequest`       | DOR-SIBLINGS-F1 D4 (Stage 2)     |
27//! | `CapabilityDeclaration` | DOR-SIBLINGS-F1 D5            |
28//! | `SurfaceSection`      | DOR-SIBLINGS-F1 D7              |
29//! | `RouteMount`          | SIBLING-INTEGRATION-SPEC §1.4   |
30//! | `SiblingStatus`       | DOR-SIBLINGS-F1 D4 (lifecycle)  |
31
32use std::sync::Arc;
33
34use crate::extension::{AfterRing, AroundRing, BeforeRing, ThroughRing};
35
36// ── Sibling lifecycle status ────────────────────────────────────────────────
37
38/// Lifecycle state of a registered sibling.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum SiblingStatus {
42    /// Stage 1: Manifest accepted, validation passed.
43    Validated,
44    /// Stage 2: Persistence domain provisioned, resources allocated.
45    Provisioned,
46    /// Stage 3: Extensions registered in ring engine.
47    Registered,
48    /// Stage 4: Sibling active and receiving commands.
49    Active,
50    /// Registration failed at some stage.
51    Failed,
52}
53
54impl std::fmt::Display for SiblingStatus {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Validated => write!(f, "validated"),
58            Self::Provisioned => write!(f, "provisioned"),
59            Self::Registered => write!(f, "registered"),
60            Self::Active => write!(f, "active"),
61            Self::Failed => write!(f, "failed"),
62        }
63    }
64}
65
66// ── Resource request types ──────────────────────────────────────────────────
67
68/// Request for a persistence domain (Stage 2: Provision).
69///
70/// Application example: maps to `SiblingDomainRequest` in soma-store, which
71/// creates `/data/siblings/{sibling_id}/{sibling_id}.redb` with the requested capacity.
72#[derive(Debug, Clone)]
73pub struct DomainRequest {
74    /// Unique sibling identifier (e.g., "qi", "git", "work").
75    pub sibling_id: String,
76    /// Human-readable display name (e.g., "Quality Intelligence").
77    pub display_name: String,
78    /// Sibling version (semver).
79    pub version: String,
80    /// Optional capacity limit in bytes. Defaults to 256 MiB if None.
81    pub max_capacity_bytes: Option<u64>,
82}
83
84/// Declaration of a capability the sibling provides.
85///
86/// Capabilities are registered with the host ring application for RBAC evaluation
87/// (the application assigns to GUARD). Each capability maps to a permission that can be granted to roles.
88#[derive(Debug, Clone)]
89pub struct CapabilityDeclaration {
90    /// Machine-readable capability name (e.g., "qi.contract.verify").
91    pub name: String,
92    /// Human-readable description.
93    pub description: String,
94}
95
96/// A section the sibling contributes to the SURFACE (ring UI).
97///
98/// Surface sections appear in the ring navigator and can render
99/// sibling-specific panel content.
100#[derive(Debug, Clone)]
101pub struct SurfaceSection {
102    /// Section identifier (kebab-case, e.g., "qi-contracts").
103    pub id: String,
104    /// Human-readable label for the navigator.
105    pub label: String,
106    /// Optional icon identifier.
107    pub icon: Option<String>,
108}
109
110/// HTTP method for structural route declarations.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum HttpMethod {
113    Get,
114    Post,
115    Put,
116    Delete,
117}
118
119impl std::fmt::Display for HttpMethod {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Get => write!(f, "GET"),
123            Self::Post => write!(f, "POST"),
124            Self::Put => write!(f, "PUT"),
125            Self::Delete => write!(f, "DELETE"),
126        }
127    }
128}
129
130/// A structural HTTP route the sibling needs mounted at startup.
131///
132/// Structural routes are for OAuth callbacks, webhooks, and other
133/// HTTP endpoints that cannot flow through the command gateway.
134/// Commands should use ThroughRing dispatch, not structural routes.
135#[derive(Debug, Clone)]
136pub struct RouteMount {
137    /// URL path (e.g., "/sibling/git/webhook").
138    pub path: String,
139    /// HTTP method.
140    pub method: HttpMethod,
141    /// Human-readable description.
142    pub description: String,
143}
144
145// ── SiblingManifest trait ───────────────────────────────────────────────────
146
147/// The structural contract for a ring-hosted sibling.
148///
149/// Every sibling implements this trait to declare
150/// its identity, ring extensions, resource requirements, and structural
151/// routes. The registration lifecycle (validate → provision → register →
152/// activate) consumes this manifest.
153///
154/// ## Minimal implementation
155///
156/// A sibling only needs identity methods and `through_handler()`.
157/// All other methods have sensible defaults (None or empty Vec).
158///
159/// ## Extension ownership
160///
161/// - `through_handler()` returns `Arc` (shared ownership for dispatch)
162/// - `before_gate()`, `after_observer()`, `around_extension()` return `Box`
163///   (exclusive ownership, consistent with `RingEngine::register_*`)
164pub trait SiblingManifest: Send + Sync {
165    // ── Identity ────────────────────────────────────────────────────────
166
167    /// Unique sibling identifier (kebab-case, e.g., "qi", "git", "work").
168    fn id(&self) -> &str;
169
170    /// Human-readable display name (e.g., "Quality Intelligence").
171    fn display_name(&self) -> &str;
172
173    /// Sibling version (semver, e.g., "0.1.0").
174    fn version(&self) -> &str;
175
176    /// Minimum required ring API version (semver).
177    fn min_ring_version(&self) -> &str;
178
179    // ── Ring extensions (typed factory methods) ─────────────────────────
180
181    /// Command handler for the sibling's FU.Data namespace.
182    ///
183    /// The returned ThroughRing should claim `sibling.{id}.*` prefix.
184    /// This is the primary interaction model — commands flow through
185    /// the ring, not through HTTP routes.
186    fn through_handler(&self) -> Option<Arc<dyn ThroughRing>>;
187
188    /// Pre-ring gate (rare — only for command rejection).
189    fn before_gate(&self) -> Option<Box<dyn BeforeRing>> {
190        None
191    }
192
193    /// Post-cycle observer (for ring output analysis).
194    fn after_observer(&self) -> Option<Box<dyn AfterRing>> {
195        None
196    }
197
198    /// Two Doors injection/observation.
199    fn around_extension(&self) -> Option<Box<dyn AroundRing>> {
200        None
201    }
202
203    // ── Resource declarations ───────────────────────────────────────────
204
205    /// Persistence domains this sibling needs provisioned.
206    fn persistence_domains(&self) -> Vec<DomainRequest> {
207        vec![]
208    }
209
210    /// Capabilities this sibling provides (registered with the host ring application for RBAC).
211    fn capabilities(&self) -> Vec<CapabilityDeclaration> {
212        vec![]
213    }
214
215    /// Surface sections this sibling contributes to the ring UI.
216    fn surface_sections(&self) -> Vec<SurfaceSection> {
217        vec![]
218    }
219
220    /// Structural HTTP routes (OAuth callbacks, webhooks — NOT commands).
221    ///
222    /// Commands should flow through ThroughRing dispatch via POST /api/command.
223    /// Only declare structural routes for endpoints that cannot use the
224    /// command gateway (e.g., OAuth redirects, webhook receivers).
225    fn structural_routes(&self) -> Vec<RouteMount> {
226        vec![]
227    }
228
229    // ── RBAC ────────────────────────────────────────────────────────────
230
231    /// Permission requirements this sibling declares for its commands.
232    ///
233    /// By default, delegates to the `through_handler`'s
234    /// `permission_requirements()`. Siblings that need to declare
235    /// permissions beyond their through handler (e.g., for before_gate or
236    /// around_extension commands) override this.
237    ///
238    /// Requirements returned here are collected into the
239    /// [`crate::authorization::PermissionRegistry`] during sibling
240    /// registration.
241    fn permission_requirements(&self) -> Vec<crate::authorization::PermissionRequirement> {
242        self.through_handler()
243            .map(|h| h.permission_requirements())
244            .unwrap_or_default()
245    }
246}
247
248// ── Registration error ──────────────────────────────────────────────────────
249
250/// Error during sibling registration lifecycle, lexicon validation, and
251/// ring-extension registration. (Unified from the previously-divergent
252/// `extension::RegistrationError` and `sibling::RegistrationError` defs.)
253#[derive(Debug, Clone)]
254#[non_exhaustive]
255pub enum RegistrationError {
256    /// Sibling ID is invalid (empty, wrong format, reserved).
257    InvalidId(String),
258    /// Sibling ID is already registered.
259    DuplicateId(String),
260    /// A `ThroughRing` delegate already claims this namespace prefix.
261    NamespaceConflict { prefix: String, claimed_by: String },
262    /// Structural route collides with an existing route.
263    RouteCollision(String),
264    /// Sibling requires a newer ring version than currently running.
265    IncompatibleVersion { required: String, running: String },
266    /// Persistence domain provisioning failed.
267    ProvisionFailed(String),
268    /// Lexicon coordinate validation failed — a vocabulary entry's unit
269    /// coordinate does not match the organ's declared unit.
270    CoordinateValidation(String),
271}
272
273impl std::fmt::Display for RegistrationError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::InvalidId(id) => write!(f, "invalid sibling ID: {id}"),
277            Self::DuplicateId(id) => write!(f, "sibling '{id}' already registered"),
278            Self::NamespaceConflict { prefix, claimed_by } => {
279                write!(f, "namespace '{prefix}' already claimed by '{claimed_by}'")
280            }
281            Self::RouteCollision(path) => write!(f, "route collision: {path}"),
282            Self::IncompatibleVersion { required, running } => {
283                write!(f, "sibling requires ring {required}, running {running}")
284            }
285            Self::ProvisionFailed(reason) => write!(f, "provisioning failed: {reason}"),
286            Self::CoordinateValidation(msg) => {
287                write!(f, "lexicon coordinate validation failed: {msg}")
288            }
289        }
290    }
291}
292
293impl std::error::Error for RegistrationError {}
294
295// ── Tests ───────────────────────────────────────────────────────────────────
296
297// inline: exercises module-private items via super::*
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn sibling_manifest_is_object_safe() {
304        fn _assert(_: &dyn SiblingManifest) {}
305    }
306
307    #[test]
308    fn sibling_status_display() {
309        assert_eq!(SiblingStatus::Validated.to_string(), "validated");
310        assert_eq!(SiblingStatus::Provisioned.to_string(), "provisioned");
311        assert_eq!(SiblingStatus::Registered.to_string(), "registered");
312        assert_eq!(SiblingStatus::Active.to_string(), "active");
313        assert_eq!(SiblingStatus::Failed.to_string(), "failed");
314    }
315
316    #[test]
317    fn sibling_status_equality() {
318        assert_eq!(SiblingStatus::Active, SiblingStatus::Active);
319        assert_ne!(SiblingStatus::Active, SiblingStatus::Failed);
320    }
321
322    #[test]
323    fn registration_error_display() {
324        let e = RegistrationError::InvalidId("bad!id".into());
325        assert_eq!(e.to_string(), "invalid sibling ID: bad!id");
326
327        let e = RegistrationError::DuplicateId("qi".into());
328        assert_eq!(e.to_string(), "sibling 'qi' already registered");
329
330        let e = RegistrationError::NamespaceConflict { prefix: "sibling.qi.*".into(), claimed_by: "qi".into() };
331        assert_eq!(e.to_string(), "namespace 'sibling.qi.*' already claimed by 'qi'");
332
333        let e = RegistrationError::RouteCollision("/oauth/callback".into());
334        assert_eq!(e.to_string(), "route collision: /oauth/callback");
335
336        let e = RegistrationError::IncompatibleVersion {
337            required: "0.2.0".into(),
338            running: "0.1.0".into(),
339        };
340        assert_eq!(e.to_string(), "sibling requires ring 0.2.0, running 0.1.0");
341
342        let e = RegistrationError::ProvisionFailed("disk full".into());
343        assert_eq!(e.to_string(), "provisioning failed: disk full");
344    }
345
346    #[test]
347    fn http_method_display() {
348        assert_eq!(HttpMethod::Get.to_string(), "GET");
349        assert_eq!(HttpMethod::Post.to_string(), "POST");
350        assert_eq!(HttpMethod::Put.to_string(), "PUT");
351        assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
352    }
353
354    #[test]
355    fn domain_request_creation() {
356        let req = DomainRequest {
357            sibling_id: "qi".into(),
358            display_name: "Quality Intelligence".into(),
359            version: "0.1.0".into(),
360            max_capacity_bytes: Some(128 * 1024 * 1024),
361        };
362        assert_eq!(req.sibling_id, "qi");
363        assert_eq!(req.max_capacity_bytes, Some(128 * 1024 * 1024));
364    }
365
366    #[test]
367    fn capability_declaration_creation() {
368        let cap = CapabilityDeclaration {
369            name: "qi.contract.verify".into(),
370            description: "Verify QI contracts".into(),
371        };
372        assert_eq!(cap.name, "qi.contract.verify");
373    }
374
375    #[test]
376    fn surface_section_creation() {
377        let section = SurfaceSection {
378            id: "qi-contracts".into(),
379            label: "QI Contracts".into(),
380            icon: Some("shield".into()),
381        };
382        assert_eq!(section.id, "qi-contracts");
383        assert_eq!(section.icon, Some("shield".into()));
384    }
385
386    #[test]
387    fn route_mount_creation() {
388        let route = RouteMount {
389            path: "/sibling/git/webhook".into(),
390            method: HttpMethod::Post,
391            description: "Git webhook receiver".into(),
392        };
393        assert_eq!(route.path, "/sibling/git/webhook");
394        assert_eq!(route.method, HttpMethod::Post);
395    }
396}