Skip to main content

soma_som_ring/
command.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2#![allow(missing_docs, clippy::should_implement_trait)]
3
4//! Ring-mediated command protocol — types, injection, and extraction.
5//!
6//! ## Spec traceability
7//! - 3-domain persistence: OU is the sole persistence boundary
8//! - Ring command payload shape: type + payload (JSON-serializable) + actor
9//!
10//! ## Design
11//!
12//! Commands are encoded as `command.*` key-value entries in FU.Data.
13//! Results are encoded as `result.*` entries in OU/SU output.
14//! This module is transport-agnostic: it defines the ring protocol shape,
15//! not application-layer routing logic.
16//!
17//! ## Key namespace
18//!
19//! | Key                  | Written by | Description                              |
20//! |----------------------|------------|------------------------------------------|
21//! | `command.type`       | Web → FU   | Command identifier (e.g. `user.create`)  |
22//! | `command.payload`    | Web → FU   | JSON-serialized command parameters       |
23//! | `command.request_id` | Web → FU   | Correlation ID for request tracking      |
24//! | `result.status`      | OU → SU    | `success` or `error`                     |
25//! | `result.command_type` | OU → SU   | Echo of the processed command type       |
26//! | `result.payload`     | OU → SU    | JSON-serialized result data              |
27//! | `result.error`       | OU → SU    | Error message (if status = error)        |
28//! | `view.id`            | Web → FU   | View identifier (e.g. `organ.mirror`) |
29//! | `view.request_id`    | Web → FU   | Correlation ID for view-request tracking |
30
31use serde::{Deserialize, Serialize};
32use soma_som_core::quad::{Quad, Tree};
33
34// ── Command type constants ───────────────────────────────────────────────
35
36/// Key prefix for all command entries in FU.Data.
37pub const COMMAND_PREFIX: &str = "command.";
38
39/// Key prefix for all result entries in OU/SU output.
40pub const RESULT_PREFIX: &str = "result.";
41
42
43// ── Command envelope ─────────────────────────────────────────────────────
44
45/// A ring command injected into FU.Data for mediated processing.
46///
47/// The command envelope carries the operation type, a JSON payload,
48/// the requesting actor identity, their role key, and a correlation ID.
49/// All fields serialize to `command.*` Tree entries via [`RingCommand::inject_into`].
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct RingCommand {
52    /// Command type identifier (e.g. `user.create`).
53    pub command_type: String,
54
55    /// JSON-serialized command parameters.
56    pub payload: String,
57
58    /// Identity of the actor making the request (application-defined label).
59    ///
60    /// CU uses this for authorization decisions. Injected into the Tree
61    /// as `command.admin` by convention; applications may interpret this
62    /// field according to their own identity model.
63    pub actor: String,
64
65    /// Role key of the requesting actor (application-defined label).
66    ///
67    /// CU evaluates permissions based on this role key. Injected into
68    /// the Tree as `command.role` by convention.
69    pub role_key: String,
70
71    /// Request correlation ID for tracing.
72    ///
73    /// Generated by the web layer, carried through all 6 units,
74    /// returned in the result for response correlation.
75    pub request_id: String,
76}
77
78impl RingCommand {
79    /// Create a new ring command.
80    pub fn new(
81        command_type: impl Into<String>,
82        payload: impl Into<String>,
83        actor: impl Into<String>,
84        role_key: impl Into<String>,
85        request_id: impl Into<String>,
86    ) -> Self {
87        Self {
88            command_type: command_type.into(),
89            payload: payload.into(),
90            actor: actor.into(),
91            role_key: role_key.into(),
92            request_id: request_id.into(),
93        }
94    }
95
96    /// Inject this command into a Tree as `command.*` entries.
97    pub fn inject_into(&self, tree: &mut Tree) {
98        tree.insert("command.type".into(), self.command_type.as_bytes().to_vec());
99        tree.insert("command.payload".into(), self.payload.as_bytes().to_vec());
100        tree.insert("command.admin".into(), self.actor.as_bytes().to_vec());
101        tree.insert("command.role".into(), self.role_key.as_bytes().to_vec());
102        tree.insert(
103            "command.request_id".into(),
104            self.request_id.as_bytes().to_vec(),
105        );
106    }
107
108    /// Extract a command from a Tree's `command.*` entries.
109    ///
110    /// Returns `None` if `command.type` is absent (not a command cycle).
111    pub fn extract_from(tree: &Tree) -> Option<Self> {
112        let command_type = tree
113            .get("command.type")
114            .map(|v| String::from_utf8_lossy(v).into_owned())?;
115
116        let payload = tree
117            .get("command.payload")
118            .map(|v| String::from_utf8_lossy(v).into_owned())
119            .unwrap_or_default();
120
121        let actor = tree
122            .get("command.admin")
123            .map(|v| String::from_utf8_lossy(v).into_owned())
124            .unwrap_or_default();
125
126        let role_key = tree
127            .get("command.role")
128            .map(|v| String::from_utf8_lossy(v).into_owned())
129            .unwrap_or_default();
130
131        let request_id = tree
132            .get("command.request_id")
133            .map(|v| String::from_utf8_lossy(v).into_owned())
134            .unwrap_or_default();
135
136        Some(Self {
137            command_type,
138            payload,
139            actor,
140            role_key,
141            request_id,
142        })
143    }
144
145    /// Check if a Tree contains a command (has `command.type`).
146    pub fn is_command(tree: &Tree) -> bool {
147        tree.contains_key("command.type")
148    }
149}
150
151// ── Result envelope ──────────────────────────────────────────────────────
152
153/// Result status values.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[non_exhaustive]
156pub enum CommandStatus {
157    /// Command executed successfully.
158    Success,
159    /// Command was denied by CU (authorization failure).
160    Denied,
161    /// Command execution failed (DIRECTOR error, validation, etc.).
162    Error,
163}
164
165impl CommandStatus {
166    pub fn as_str(&self) -> &'static str {
167        match self {
168            CommandStatus::Success => "success",
169            CommandStatus::Denied => "denied",
170            CommandStatus::Error => "error",
171        }
172    }
173
174    /// Parse from a string value.
175    pub fn from_str(s: &str) -> Option<Self> {
176        match s {
177            "success" => Some(CommandStatus::Success),
178            "denied" => Some(CommandStatus::Denied),
179            "error" => Some(CommandStatus::Error),
180            _ => None,
181        }
182    }
183}
184
185/// A command result written by OU into the ring output.
186///
187/// SU reads this to produce the final result descriptor.
188/// The web layer reads the descriptor from SU's output.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct CommandResult {
191    /// Execution status.
192    pub status: CommandStatus,
193
194    /// Echo of the command type that was processed.
195    pub command_type: String,
196
197    /// JSON-serialized result data (on success).
198    pub payload: String,
199
200    /// Error message (on error/denied).
201    pub error: String,
202
203    /// Correlation ID from the original command.
204    pub request_id: String,
205}
206
207impl CommandResult {
208    /// Create a success result.
209    pub fn success(
210        command_type: impl Into<String>,
211        payload: impl Into<String>,
212        request_id: impl Into<String>,
213    ) -> Self {
214        Self {
215            status: CommandStatus::Success,
216            command_type: command_type.into(),
217            payload: payload.into(),
218            error: String::new(),
219            request_id: request_id.into(),
220        }
221    }
222
223    /// Create a denied result (CU rejected authorization).
224    pub fn denied(
225        command_type: impl Into<String>,
226        reason: impl Into<String>,
227        request_id: impl Into<String>,
228    ) -> Self {
229        Self {
230            status: CommandStatus::Denied,
231            command_type: command_type.into(),
232            payload: String::new(),
233            error: reason.into(),
234            request_id: request_id.into(),
235        }
236    }
237
238    /// Create an error result (execution failure).
239    pub fn error(
240        command_type: impl Into<String>,
241        error: impl Into<String>,
242        request_id: impl Into<String>,
243    ) -> Self {
244        Self {
245            status: CommandStatus::Error,
246            command_type: command_type.into(),
247            payload: String::new(),
248            error: error.into(),
249            request_id: request_id.into(),
250        }
251    }
252
253    /// Write this result into a Tree as `result.*` entries.
254    pub fn inject_into(&self, tree: &mut Tree) {
255        tree.insert(
256            "result.status".into(),
257            self.status.as_str().as_bytes().to_vec(),
258        );
259        tree.insert(
260            "result.command_type".into(),
261            self.command_type.as_bytes().to_vec(),
262        );
263        tree.insert("result.payload".into(), self.payload.as_bytes().to_vec());
264        tree.insert("result.error".into(), self.error.as_bytes().to_vec());
265        tree.insert(
266            "result.request_id".into(),
267            self.request_id.as_bytes().to_vec(),
268        );
269    }
270
271    /// Extract a result from a Tree's `result.*` entries.
272    ///
273    /// Returns `None` if `result.status` is absent (not a result cycle).
274    pub fn extract_from(tree: &Tree) -> Option<Self> {
275        let status_str = tree
276            .get("result.status")
277            .map(|v| String::from_utf8_lossy(v).into_owned())?;
278        let status = CommandStatus::from_str(&status_str)?;
279
280        let command_type = tree
281            .get("result.command_type")
282            .map(|v| String::from_utf8_lossy(v).into_owned())
283            .unwrap_or_default();
284
285        let payload = tree
286            .get("result.payload")
287            .map(|v| String::from_utf8_lossy(v).into_owned())
288            .unwrap_or_default();
289
290        let error = tree
291            .get("result.error")
292            .map(|v| String::from_utf8_lossy(v).into_owned())
293            .unwrap_or_default();
294
295        let request_id = tree
296            .get("result.request_id")
297            .map(|v| String::from_utf8_lossy(v).into_owned())
298            .unwrap_or_default();
299
300        Some(Self {
301            status,
302            command_type,
303            payload,
304            error,
305            request_id,
306        })
307    }
308
309    /// Check if a Tree contains a result (has `result.status`).
310    pub fn is_result(tree: &Tree) -> bool {
311        tree.contains_key("result.status")
312    }
313}
314
315// ── Quad-level injection helper ──────────────────────────────────────────
316
317/// Inject a ring command into a Quad's Tree, producing a new Quad.
318///
319/// Follows the same pattern as `inject_login_event()`: preserves
320/// existing Tree entries, adds `command.*` entries, recomputes Root.
321///
322/// ## Usage
323///
324/// ```rust
325/// use soma_som_ring::command::{RingCommand, inject_command};
326/// use soma_som_core::quad::Quad;
327///
328/// let quad = Quad::from_strings("root", "ptr", soma_som_core::quad::Tree::new());
329/// let cmd = RingCommand::new("user.create", "{}", "admin", "admin", "req-001");
330/// let injected = inject_command(&quad, &cmd);
331/// assert!(injected.tree.contains_key("command.type"));
332/// ```
333pub fn inject_command(quad: &Quad, command: &RingCommand) -> Quad {
334    let mut tree = quad.tree.clone();
335    command.inject_into(&mut tree);
336
337    // Recompute root to reflect the new tree content
338    let root = {
339        let mut hasher = blake3::Hasher::new();
340        hasher.update(b"command_injection");
341        hasher.update(&quad.root);
342        hasher.update(command.command_type.as_bytes());
343        hasher.update(command.request_id.as_bytes());
344        *hasher.finalize().as_bytes()
345    };
346
347    Quad::new(root, quad.pointer, tree)
348}
349
350// ── Schema Validation ──────────────────────────────────────────────────────
351//
352// Foundation-tier validation is structural only: required-field check + JSON
353// type check. The `field.validation` S-FEEL expression on CommandSchema is
354// retained as data but NOT evaluated here — evaluator choice is a §13.2
355// implementation-variable and lives in the ring application via its registered
356// validator (OPUS §13-agnostic).
357
358/// Validate a command payload against a schema.
359///
360/// Returns `Ok(())` if no schema exists for this command type (open by default)
361/// or if the payload conforms to the schema. Returns `Err` with a description
362/// of all validation failures otherwise.
363///
364/// Validation happens at the injection boundary — before the command enters
365/// the ring. Invalid payloads never cross the Two Doors threshold.
366pub fn validate_payload(
367    schema: &soma_som_core::extension::CommandSchema,
368    payload: &str,
369) -> Result<(), String> {
370    // Empty payload with no required fields is valid
371    if payload.is_empty() || payload == "{}" {
372        let has_required = schema.fields.iter().any(|f| f.required);
373        if has_required {
374            let missing: Vec<&str> = schema
375                .fields
376                .iter()
377                .filter(|f| f.required)
378                .map(|f| f.name.as_str())
379                .collect();
380            return Err(format!(
381                "payload validation failed for '{}': missing required fields: {}",
382                schema.command_type,
383                missing.join(", ")
384            ));
385        }
386        return Ok(());
387    }
388
389    let value: serde_json::Value = serde_json::from_str(payload).map_err(|e| {
390        format!(
391            "payload validation failed for '{}': invalid JSON: {e}",
392            schema.command_type
393        )
394    })?;
395
396    let obj = match value.as_object() {
397        Some(o) => o,
398        None => {
399            return Err(format!(
400                "payload validation failed for '{}': payload must be a JSON object",
401                schema.command_type
402            ));
403        }
404    };
405
406    let mut errors = Vec::new();
407
408    for field in &schema.fields {
409        match obj.get(&field.name) {
410            None if field.required => {
411                errors.push(format!("missing required field '{}'", field.name));
412            }
413            None => {} // optional absent — ok (FEEL validation skipped)
414            Some(val) => {
415                use soma_som_core::extension::SchemaFieldType;
416                let type_ok = match field.field_type {
417                    SchemaFieldType::String => val.is_string(),
418                    SchemaFieldType::Number => val.is_number(),
419                    SchemaFieldType::Boolean => val.is_boolean(),
420                    SchemaFieldType::Object => val.is_object(),
421                    SchemaFieldType::Array => val.is_array(),
422                    // SchemaFieldType is #[non_exhaustive]: a future variant
423                    // we do not yet know how to validate fails closed.
424                    _ => false,
425                };
426                if !type_ok {
427                    let actual = match val {
428                        serde_json::Value::Null => "null",
429                        serde_json::Value::Bool(_) => "boolean",
430                        serde_json::Value::Number(_) => "number",
431                        serde_json::Value::String(_) => "string",
432                        serde_json::Value::Array(_) => "array",
433                        serde_json::Value::Object(_) => "object",
434                    };
435                    errors.push(format!(
436                        "field '{}' expected type '{}', got '{}'",
437                        field.name, field.field_type, actual,
438                    ));
439                }
440                // S-FEEL validation expression evaluation is a §13.2
441                // implementation-variable (OPUS §13). The `field.validation`
442                // expression stays on the schema as data; ring applications
443                // evaluate it via their own registered validator.
444            }
445        }
446    }
447
448    if errors.is_empty() {
449        Ok(())
450    } else {
451        Err(format!(
452            "payload validation failed for '{}': {}",
453            schema.command_type,
454            errors.join("; ")
455        ))
456    }
457}
458
459// ── View envelope ────────────────────────────────────────────────────
460
461/// Key prefix for all view-intent entries in FU.Data.
462///
463/// Disjoint from [`COMMAND_PREFIX`] and [`RESULT_PREFIX`] so a single Tree
464/// may carry a view intent alongside a command or event without collision.
465pub const VIEW_PREFIX: &str = "view.";
466
467/// A ring view intent injected into FU.Data for mediated view projection.
468///
469/// `ViewIntent` is to views what [`RingCommand`] is to commands: the
470/// input envelope that starts a ring cycle. A view cycle produces
471/// projected output in SU (shape governed by C3's route and the
472/// organ's AROUND extension).
473///
474/// The envelope is shorter than [`RingCommand`] because views have no
475/// free-form parameters at this layer — everything needed to select
476/// data comes from `view_id` (resolved via the `ViewRegistry` in
477/// `soma-interface`) and GUARD's scope evaluation over `scope` +
478/// `role_key`.
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
480pub struct ViewIntent {
481    /// View identifier — e.g. `organ.mirror`, `term.fu.data.tree`,
482    /// `ext.git.commit.viewer`. Resolves through `ViewRegistry`.
483    pub view_id: String,
484
485    /// GUARD scope the requestor is asking under. CU uses this to
486    /// decide which projection slice is visible.
487    pub scope: String,
488
489    /// Role key of the requestor (application-defined label).
490    ///
491    /// CU uses this for authorization scope evaluation. Injected into
492    /// the Tree as `view.requestor_role` by convention.
493    pub role_key: String,
494
495    /// Request correlation ID for tracing. Generated by the web layer,
496    /// carried through the cycle.
497    pub request_id: String,
498}
499
500impl ViewIntent {
501    /// Create a new view intent.
502    pub fn new(
503        view_id: impl Into<String>,
504        scope: impl Into<String>,
505        role_key: impl Into<String>,
506        request_id: impl Into<String>,
507    ) -> Self {
508        Self {
509            view_id: view_id.into(),
510            scope: scope.into(),
511            role_key: role_key.into(),
512            request_id: request_id.into(),
513        }
514    }
515
516    /// Inject this view intent into a Tree as `view.*` entries.
517    pub fn inject_into(&self, tree: &mut Tree) {
518        tree.insert("view.id".into(), self.view_id.as_bytes().to_vec());
519        tree.insert("view.scope".into(), self.scope.as_bytes().to_vec());
520        tree.insert(
521            "view.requestor_role".into(),
522            self.role_key.as_bytes().to_vec(),
523        );
524        tree.insert(
525            "view.request_id".into(),
526            self.request_id.as_bytes().to_vec(),
527        );
528    }
529
530    /// Extract a view intent from a Tree's `view.*` entries.
531    ///
532    /// Returns `None` if `view.id` is absent (not a view cycle).
533    pub fn extract_from(tree: &Tree) -> Option<Self> {
534        let view_id = tree
535            .get("view.id")
536            .map(|v| String::from_utf8_lossy(v).into_owned())?;
537
538        let scope = tree
539            .get("view.scope")
540            .map(|v| String::from_utf8_lossy(v).into_owned())
541            .unwrap_or_default();
542
543        let role_key = tree
544            .get("view.requestor_role")
545            .map(|v| String::from_utf8_lossy(v).into_owned())
546            .unwrap_or_default();
547
548        let request_id = tree
549            .get("view.request_id")
550            .map(|v| String::from_utf8_lossy(v).into_owned())
551            .unwrap_or_default();
552
553        Some(Self {
554            view_id,
555            scope,
556            role_key,
557            request_id,
558        })
559    }
560
561    /// Check if a Tree contains a view intent (has `view.id`).
562    pub fn is_view_intent(tree: &Tree) -> bool {
563        tree.contains_key("view.id")
564    }
565}
566
567/// Inject a view intent into a Quad's Tree, producing a new Quad.
568///
569/// Mirrors [`inject_command`] — preserves existing Tree entries, adds
570/// `view.*` entries, recomputes Root. Pointer is preserved.
571///
572/// ## Usage
573///
574/// ```rust
575/// use soma_som_ring::command::{ViewIntent, inject_view_intent};
576/// use soma_som_core::quad::Quad;
577///
578/// let quad = Quad::from_strings("root", "ptr", soma_som_core::quad::Tree::new());
579/// let intent = ViewIntent::new("organ.mirror", "health", "admin", "req-001");
580/// let injected = inject_view_intent(&quad, &intent);
581/// assert!(injected.tree.contains_key("view.id"));
582/// ```
583pub fn inject_view_intent(quad: &Quad, intent: &ViewIntent) -> Quad {
584    let mut tree = quad.tree.clone();
585    intent.inject_into(&mut tree);
586
587    let root = {
588        let mut hasher = blake3::Hasher::new();
589        hasher.update(b"view_intent_injection");
590        hasher.update(&quad.root);
591        hasher.update(intent.view_id.as_bytes());
592        hasher.update(intent.request_id.as_bytes());
593        *hasher.finalize().as_bytes()
594    };
595
596    Quad::new(root, quad.pointer, tree)
597}
598
599// ── Tests ────────────────────────────────────────────────────────────────
600
601// inline: exercises module-private items via super::*
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use soma_som_core::quad::Quad;
606
607    // ── RingCommand construction ─────────────────────────────────────
608
609    #[test]
610    fn ring_command_new() {
611        let cmd = RingCommand::new(
612            "user.create",
613            "{\"username\":\"alice\"}",
614            "admin",
615            "admin",
616            "req-001",
617        );
618        assert_eq!(cmd.command_type, "user.create");
619        assert_eq!(cmd.actor, "admin");
620        assert_eq!(cmd.role_key, "admin");
621        assert_eq!(cmd.request_id, "req-001");
622    }
623
624    // ── Tree injection / extraction roundtrip ────────────────────────
625
626    #[test]
627    fn command_inject_extract_roundtrip() {
628        let cmd = RingCommand::new(
629            "user.delete",
630            "{\"username\":\"bob\"}",
631            "admin",
632            "admin",
633            "req-002",
634        );
635        let mut tree = Tree::new();
636        cmd.inject_into(&mut tree);
637
638        let extracted = RingCommand::extract_from(&tree).expect("should extract");
639        assert_eq!(extracted, cmd);
640    }
641
642    #[test]
643    fn command_extract_returns_none_without_type() {
644        let tree = Tree::new();
645        assert!(RingCommand::extract_from(&tree).is_none());
646    }
647
648    #[test]
649    fn command_is_command_detection() {
650        let mut tree = Tree::new();
651        assert!(!RingCommand::is_command(&tree));
652        tree.insert("command.type".into(), b"user.list".to_vec());
653        assert!(RingCommand::is_command(&tree));
654    }
655
656    // ── Quad-level injection ─────────────────────────────────────────
657
658    #[test]
659    fn inject_command_preserves_existing_tree() {
660        let mut tree = Tree::new();
661        tree.insert("existing.key".into(), b"value".to_vec());
662        let quad = Quad::from_strings("root", "ptr", tree);
663
664        let cmd = RingCommand::new("user.list", "{}", "admin", "admin", "req-003");
665        let injected = inject_command(&quad, &cmd);
666
667        assert_eq!(
668            injected.tree.get("existing.key"),
669            Some(&b"value".to_vec()),
670            "existing keys must be preserved"
671        );
672        assert!(injected.tree.contains_key("command.type"));
673    }
674
675    #[test]
676    fn inject_command_recomputes_root() {
677        let quad = Quad::from_strings("root", "ptr", Tree::new());
678        let cmd = RingCommand::new("user.create", "{}", "admin", "admin", "req-004");
679        let injected = inject_command(&quad, &cmd);
680
681        assert_ne!(
682            injected.root, quad.root,
683            "root must change after command injection"
684        );
685    }
686
687    #[test]
688    fn inject_command_different_commands_different_roots() {
689        let quad = Quad::from_strings("root", "ptr", Tree::new());
690        let cmd_a = RingCommand::new("user.create", "{}", "admin", "admin", "req-a");
691        let cmd_b = RingCommand::new("user.delete", "{}", "admin", "admin", "req-b");
692
693        let injected_a = inject_command(&quad, &cmd_a);
694        let injected_b = inject_command(&quad, &cmd_b);
695
696        assert_ne!(
697            injected_a.root, injected_b.root,
698            "different commands must produce different roots"
699        );
700    }
701
702    #[test]
703    fn inject_command_preserves_pointer() {
704        let quad = Quad::from_strings("root", "ptr", Tree::new());
705        let cmd = RingCommand::new("user.list", "{}", "admin", "admin", "req-005");
706        let injected = inject_command(&quad, &cmd);
707
708        assert_eq!(injected.pointer, quad.pointer, "pointer must be preserved");
709    }
710
711    // ── CommandResult construction ───────────────────────────────────
712
713    #[test]
714    fn result_success() {
715        let r = CommandResult::success("user.create", "{\"username\":\"alice\"}", "req-001");
716        assert_eq!(r.status, CommandStatus::Success);
717        assert_eq!(r.command_type, "user.create");
718        assert!(!r.payload.is_empty());
719        assert!(r.error.is_empty());
720    }
721
722    #[test]
723    fn result_denied() {
724        let r = CommandResult::denied("user.delete", "insufficient privileges", "req-002");
725        assert_eq!(r.status, CommandStatus::Denied);
726        assert!(r.payload.is_empty());
727        assert_eq!(r.error, "insufficient privileges");
728    }
729
730    #[test]
731    fn result_error() {
732        let r = CommandResult::error("user.create", "username already exists", "req-003");
733        assert_eq!(r.status, CommandStatus::Error);
734        assert_eq!(r.error, "username already exists");
735    }
736
737    // ── Result Tree injection / extraction roundtrip ─────────────────
738
739    #[test]
740    fn result_inject_extract_roundtrip() {
741        let r = CommandResult::success("user.list", "[{\"username\":\"admin\"}]", "req-004");
742        let mut tree = Tree::new();
743        r.inject_into(&mut tree);
744
745        let extracted = CommandResult::extract_from(&tree).expect("should extract");
746        assert_eq!(extracted, r);
747    }
748
749    #[test]
750    fn result_extract_returns_none_without_status() {
751        let tree = Tree::new();
752        assert!(CommandResult::extract_from(&tree).is_none());
753    }
754
755    #[test]
756    fn result_is_result_detection() {
757        let mut tree = Tree::new();
758        assert!(!CommandResult::is_result(&tree));
759        tree.insert("result.status".into(), b"success".to_vec());
760        assert!(CommandResult::is_result(&tree));
761    }
762
763    #[test]
764    fn result_denied_roundtrip() {
765        let r = CommandResult::denied("user.delete", "not authorized", "req-005");
766        let mut tree = Tree::new();
767        r.inject_into(&mut tree);
768
769        let extracted = CommandResult::extract_from(&tree).unwrap();
770        assert_eq!(extracted.status, CommandStatus::Denied);
771        assert_eq!(extracted.error, "not authorized");
772    }
773
774    #[test]
775    fn result_error_roundtrip() {
776        let r = CommandResult::error("user.create", "db write failed", "req-006");
777        let mut tree = Tree::new();
778        r.inject_into(&mut tree);
779
780        let extracted = CommandResult::extract_from(&tree).unwrap();
781        assert_eq!(extracted.status, CommandStatus::Error);
782        assert_eq!(extracted.error, "db write failed");
783    }
784
785    // ── CommandStatus parsing ────────────────────────────────────────
786
787    #[test]
788    fn command_status_roundtrip() {
789        for status in [
790            CommandStatus::Success,
791            CommandStatus::Denied,
792            CommandStatus::Error,
793        ] {
794            let parsed = CommandStatus::from_str(status.as_str()).unwrap();
795            assert_eq!(parsed, status);
796        }
797    }
798
799    #[test]
800    fn command_status_unknown_returns_none() {
801        assert!(CommandStatus::from_str("unknown").is_none());
802        assert!(CommandStatus::from_str("").is_none());
803    }
804
805    // ── Serde roundtrip ──────────────────────────────────────────────
806
807    #[test]
808    fn ring_command_serde_roundtrip() {
809        let cmd = RingCommand::new(
810            "user.create",
811            "{\"username\":\"carol\"}",
812            "admin",
813            "admin",
814            "req-007",
815        );
816        let json = serde_json::to_string(&cmd).unwrap();
817        let decoded: RingCommand = serde_json::from_str(&json).unwrap();
818        assert_eq!(decoded, cmd);
819    }
820
821    #[test]
822    fn command_result_serde_roundtrip() {
823        let r = CommandResult::success("user.list", "[]", "req-008");
824        let json = serde_json::to_string(&r).unwrap();
825        let decoded: CommandResult = serde_json::from_str(&json).unwrap();
826        assert_eq!(decoded, r);
827    }
828
829    // ── Key namespace isolation ──────────────────────────────────────
830
831    #[test]
832    fn command_keys_use_command_prefix() {
833        let cmd = RingCommand::new("user.create", "{}", "admin", "admin", "req-009");
834        let mut tree = Tree::new();
835        cmd.inject_into(&mut tree);
836
837        for key in tree.keys() {
838            assert!(
839                key.starts_with(COMMAND_PREFIX),
840                "command key '{key}' must start with '{COMMAND_PREFIX}'"
841            );
842        }
843    }
844
845    #[test]
846    fn result_keys_use_result_prefix() {
847        let r = CommandResult::success("user.create", "{}", "req-010");
848        let mut tree = Tree::new();
849        r.inject_into(&mut tree);
850
851        for key in tree.keys() {
852            assert!(
853                key.starts_with(RESULT_PREFIX),
854                "result key '{key}' must start with '{RESULT_PREFIX}'"
855            );
856        }
857    }
858
859    // ── Coexistence with event.* namespace ───────────────────────────
860
861    #[test]
862    fn command_and_event_namespaces_do_not_collide() {
863        let mut tree = Tree::new();
864
865        // Inject a login event
866        tree.insert("event.type".into(), b"login_attempt".to_vec());
867        tree.insert("event.source".into(), b"web".to_vec());
868
869        // Inject a command
870        let cmd = RingCommand::new("user.list", "{}", "admin", "admin", "req-011");
871        cmd.inject_into(&mut tree);
872
873        // Both coexist
874        assert_eq!(tree.get("event.type"), Some(&b"login_attempt".to_vec()),);
875        assert_eq!(tree.get("command.type"), Some(&b"user.list".to_vec()),);
876
877        // Extraction works independently
878        assert!(RingCommand::extract_from(&tree).is_some());
879    }
880
881    // ── Payload validation tests ────────────────────────────────────
882
883    use soma_som_core::extension::{CommandSchema, SchemaField, SchemaFieldType};
884
885    fn user_create_schema() -> CommandSchema {
886        CommandSchema::new("user.create")
887            .field(SchemaField::required("username", SchemaFieldType::String))
888            .field(SchemaField::required("password", SchemaFieldType::String))
889            .field(SchemaField::optional("role", SchemaFieldType::String))
890    }
891
892    #[test]
893    fn validate_valid_payload() {
894        let schema = user_create_schema();
895        let payload = r#"{"username":"alice","password":"secret"}"#;
896        assert!(super::validate_payload(&schema, payload).is_ok());
897    }
898
899    #[test]
900    fn validate_valid_payload_with_extra_fields() {
901        let schema = user_create_schema();
902        let payload = r#"{"username":"alice","password":"secret","extra":"ignored"}"#;
903        assert!(super::validate_payload(&schema, payload).is_ok());
904    }
905
906    #[test]
907    fn validate_missing_required_field() {
908        let schema = user_create_schema();
909        let payload = r#"{"password":"secret"}"#;
910        let err = super::validate_payload(&schema, payload).unwrap_err();
911        assert!(err.contains("missing required field 'username'"));
912    }
913
914    #[test]
915    fn validate_wrong_field_type() {
916        let schema = CommandSchema::new("test.cmd")
917            .field(SchemaField::required("timeout", SchemaFieldType::Number));
918        let payload = r#"{"timeout":"not-a-number"}"#;
919        let err = super::validate_payload(&schema, payload).unwrap_err();
920        assert!(err.contains("expected type 'number'"));
921        assert!(err.contains("got 'string'"));
922    }
923
924    #[test]
925    fn validate_no_schema_fields_accepts_anything() {
926        let schema = CommandSchema::new("user.list");
927        assert!(super::validate_payload(&schema, "{}").is_ok());
928        assert!(super::validate_payload(&schema, r#"{"any":"thing"}"#).is_ok());
929    }
930
931    #[test]
932    fn validate_empty_payload_with_no_required_fields() {
933        let schema = CommandSchema::new("test.cmd")
934            .field(SchemaField::optional("debug", SchemaFieldType::Boolean));
935        assert!(super::validate_payload(&schema, "{}").is_ok());
936        assert!(super::validate_payload(&schema, "").is_ok());
937    }
938
939    #[test]
940    fn validate_empty_payload_with_required_fields_fails() {
941        let schema = user_create_schema();
942        let err = super::validate_payload(&schema, "{}").unwrap_err();
943        assert!(err.contains("missing required fields"));
944    }
945
946    #[test]
947    fn validate_invalid_json_fails() {
948        let schema = user_create_schema();
949        let err = super::validate_payload(&schema, "not json").unwrap_err();
950        assert!(err.contains("invalid JSON"));
951    }
952
953    #[test]
954    fn validate_non_object_json_fails() {
955        let schema = user_create_schema();
956        let err = super::validate_payload(&schema, r#""just a string""#).unwrap_err();
957        assert!(err.contains("must be a JSON object"));
958    }
959
960    #[test]
961    fn validate_optional_field_type_checked_when_present() {
962        let schema = CommandSchema::new("test.cmd")
963            .field(SchemaField::optional("count", SchemaFieldType::Number));
964        // Optional absent — ok.
965        assert!(super::validate_payload(&schema, "{}").is_ok());
966        // Optional present with correct type — ok.
967        assert!(super::validate_payload(&schema, r#"{"count":42}"#).is_ok());
968        // Optional present with wrong type — error.
969        let err = super::validate_payload(&schema, r#"{"count":"nope"}"#).unwrap_err();
970        assert!(err.contains("expected type 'number'"));
971    }
972
973    #[test]
974    fn validate_all_field_types() {
975        let schema = CommandSchema::new("test.types")
976            .field(SchemaField::required("s", SchemaFieldType::String))
977            .field(SchemaField::required("n", SchemaFieldType::Number))
978            .field(SchemaField::required("b", SchemaFieldType::Boolean))
979            .field(SchemaField::required("o", SchemaFieldType::Object))
980            .field(SchemaField::required("a", SchemaFieldType::Array));
981
982        let payload = r#"{"s":"x","n":1,"b":true,"o":{},"a":[]}"#;
983        assert!(super::validate_payload(&schema, payload).is_ok());
984    }
985
986    // ── ViewIntent envelope ─────────────────────────────────────────
987
988    #[test]
989    fn view_intent_new() {
990        let intent = ViewIntent::new("organ.mirror", "health", "admin", "req-001");
991        assert_eq!(intent.view_id, "organ.mirror");
992        assert_eq!(intent.scope, "health");
993        assert_eq!(intent.role_key, "admin");
994        assert_eq!(intent.request_id, "req-001");
995    }
996
997    #[test]
998    fn view_intent_inject_extract_roundtrip() {
999        let intent = ViewIntent::new(
1000            "term.fu.data.tree",
1001            "identity",
1002            "viewer",
1003            "req-002",
1004        );
1005        let mut tree = Tree::new();
1006        intent.inject_into(&mut tree);
1007
1008        let extracted = ViewIntent::extract_from(&tree).expect("should extract");
1009        assert_eq!(extracted, intent);
1010    }
1011
1012    #[test]
1013    fn view_intent_extract_returns_none_without_id() {
1014        let tree = Tree::new();
1015        assert!(ViewIntent::extract_from(&tree).is_none());
1016    }
1017
1018    #[test]
1019    fn view_intent_is_view_intent_detection() {
1020        let mut tree = Tree::new();
1021        assert!(!ViewIntent::is_view_intent(&tree));
1022        tree.insert("view.id".into(), b"organ.mirror".to_vec());
1023        assert!(ViewIntent::is_view_intent(&tree));
1024    }
1025
1026    #[test]
1027    fn inject_view_intent_preserves_existing_tree() {
1028        let mut tree = Tree::new();
1029        tree.insert("existing.key".into(), b"value".to_vec());
1030        let quad = Quad::from_strings("root", "ptr", tree);
1031
1032        let intent = ViewIntent::new("organ.guard", "policy", "admin", "req-003");
1033        let injected = inject_view_intent(&quad, &intent);
1034
1035        assert_eq!(
1036            injected.tree.get("existing.key"),
1037            Some(&b"value".to_vec()),
1038            "existing keys must be preserved"
1039        );
1040        assert!(injected.tree.contains_key("view.id"));
1041    }
1042
1043    #[test]
1044    fn inject_view_intent_recomputes_root() {
1045        let quad = Quad::from_strings("root", "ptr", Tree::new());
1046        let intent = ViewIntent::new("organ.store", "data", "admin", "req-004");
1047        let injected = inject_view_intent(&quad, &intent);
1048
1049        assert_ne!(
1050            injected.root, quad.root,
1051            "root must change after view intent injection"
1052        );
1053    }
1054
1055    #[test]
1056    fn inject_view_intent_different_intents_different_roots() {
1057        let quad = Quad::from_strings("root", "ptr", Tree::new());
1058        let intent_a = ViewIntent::new("organ.mirror", "health", "admin", "req-a");
1059        let intent_b = ViewIntent::new("organ.guard", "policy", "admin", "req-b");
1060
1061        let injected_a = inject_view_intent(&quad, &intent_a);
1062        let injected_b = inject_view_intent(&quad, &intent_b);
1063
1064        assert_ne!(
1065            injected_a.root, injected_b.root,
1066            "different view intents must produce different roots"
1067        );
1068    }
1069
1070    #[test]
1071    fn inject_view_intent_preserves_pointer() {
1072        let quad = Quad::from_strings("root", "ptr", Tree::new());
1073        let intent = ViewIntent::new("organ.wall", "perimeter", "admin", "req-005");
1074        let injected = inject_view_intent(&quad, &intent);
1075        assert_eq!(injected.pointer, quad.pointer, "pointer must be preserved");
1076    }
1077
1078    #[test]
1079    fn view_intent_serde_roundtrip() {
1080        let intent = ViewIntent::new(
1081            "term.mu.data.tree",
1082            "observability",
1083            "operator",
1084            "req-006",
1085        );
1086        let json = serde_json::to_string(&intent).unwrap();
1087        let decoded: ViewIntent = serde_json::from_str(&json).unwrap();
1088        assert_eq!(decoded, intent);
1089    }
1090
1091    #[test]
1092    fn view_intent_keys_use_view_prefix() {
1093        let intent = ViewIntent::new("organ.mirror", "health", "admin", "req-007");
1094        let mut tree = Tree::new();
1095        intent.inject_into(&mut tree);
1096
1097        for key in tree.keys() {
1098            assert!(
1099                key.starts_with(VIEW_PREFIX),
1100                "view key '{key}' must start with '{VIEW_PREFIX}'"
1101            );
1102        }
1103    }
1104
1105    // ── Namespace disjointness: view / command / event / result ─────
1106
1107    #[test]
1108    fn view_and_command_namespaces_do_not_collide() {
1109        let mut tree = Tree::new();
1110
1111        // Inject a login event
1112        tree.insert("event.type".into(), b"login_attempt".to_vec());
1113
1114        // Inject a command
1115        let cmd = RingCommand::new("user.list", "{}", "admin", "admin", "req-cmd");
1116        cmd.inject_into(&mut tree);
1117
1118        // Inject a result (pretend OU has written back already)
1119        let r = CommandResult::success("user.list", "[]", "req-cmd");
1120        r.inject_into(&mut tree);
1121
1122        // Now inject a view intent
1123        let intent = ViewIntent::new("organ.mirror", "health", "admin", "req-view");
1124        intent.inject_into(&mut tree);
1125
1126        // All four envelope types coexist
1127        assert_eq!(tree.get("event.type"), Some(&b"login_attempt".to_vec()));
1128        assert_eq!(tree.get("command.type"), Some(&b"user.list".to_vec()));
1129        assert_eq!(tree.get("result.status"), Some(&b"success".to_vec()));
1130        assert_eq!(tree.get("view.id"), Some(&b"organ.mirror".to_vec()));
1131
1132        // Each selector is-envelope function finds only its own envelope
1133        assert!(RingCommand::is_command(&tree));
1134        assert!(CommandResult::is_result(&tree));
1135        assert!(ViewIntent::is_view_intent(&tree));
1136
1137        // Each extract function returns the correct envelope
1138        let xcmd = RingCommand::extract_from(&tree).expect("cmd");
1139        let xres = CommandResult::extract_from(&tree).expect("res");
1140        let xview = ViewIntent::extract_from(&tree).expect("view");
1141
1142        assert_eq!(xcmd.command_type, "user.list");
1143        assert_eq!(xres.command_type, "user.list");
1144        assert_eq!(xview.view_id, "organ.mirror");
1145
1146        // The three request_ids stay distinct
1147        assert_eq!(xcmd.request_id, "req-cmd");
1148        assert_eq!(xres.request_id, "req-cmd");
1149        assert_eq!(xview.request_id, "req-view");
1150    }
1151
1152    #[test]
1153    fn view_intent_does_not_appear_as_command() {
1154        let mut tree = Tree::new();
1155        let intent = ViewIntent::new("organ.mirror", "health", "admin", "req-only-view");
1156        intent.inject_into(&mut tree);
1157
1158        // Pure view tree — not a command, not a result
1159        assert!(!RingCommand::is_command(&tree));
1160        assert!(!CommandResult::is_result(&tree));
1161        assert!(ViewIntent::is_view_intent(&tree));
1162
1163        assert!(RingCommand::extract_from(&tree).is_none());
1164        assert!(CommandResult::extract_from(&tree).is_none());
1165        assert!(ViewIntent::extract_from(&tree).is_some());
1166    }
1167
1168    #[test]
1169    fn view_prefix_constant_matches_key_layout() {
1170        // Guards against VIEW_PREFIX drifting out of sync with inject_into().
1171        assert_eq!(VIEW_PREFIX, "view.");
1172        let intent = ViewIntent::new("organ.mirror", "s", "r", "i");
1173        let mut tree = Tree::new();
1174        intent.inject_into(&mut tree);
1175
1176        for key in tree.keys() {
1177            assert!(
1178                key.starts_with(VIEW_PREFIX),
1179                "expected prefix '{VIEW_PREFIX}' on key '{key}'"
1180            );
1181        }
1182        // Disjoint from the other two prefixes defined in this module.
1183        assert_ne!(VIEW_PREFIX, COMMAND_PREFIX);
1184        assert_ne!(VIEW_PREFIX, RESULT_PREFIX);
1185    }
1186}