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}