Skip to main content

ferro_projections/
service.rs

1use std::collections::HashSet;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::action::{ActionDef, GuardDef};
7use crate::field::{infer_meaning, DataType, FieldDef, FieldMeaning, RenderHint};
8use crate::intent::IntentHint;
9use crate::relationship::{Cardinality, RelationshipDef};
10use crate::state::{StateMachine, Warning};
11
12/// Intermediate representation of a model for ServiceDef derivation.
13///
14/// Decouples ferro-projections from ORM-specific types. Callers populate
15/// this from their own model parsing and pass it to `ServiceDef::from_model()`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ModelMetadata {
18    pub name: String,
19    pub display_name: Option<String>,
20    pub table: Option<String>,
21    pub fields: Vec<FieldMetadata>,
22}
23
24/// Metadata for a single model field.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct FieldMetadata {
27    pub name: String,
28    /// Raw Rust/SeaORM type string (e.g., `String`, `i32`, `Option<Uuid>`).
29    pub column_type: String,
30    pub is_primary_key: bool,
31    pub is_nullable: bool,
32}
33
34/// Converts snake_case to Title Case ("order_item" -> "Order Item").
35fn snake_to_title(s: &str) -> String {
36    s.split('_')
37        .map(|word| {
38            let mut c = word.chars();
39            match c.next() {
40                None => String::new(),
41                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
42            }
43        })
44        .collect::<Vec<_>>()
45        .join(" ")
46}
47
48/// A service definition describing a domain entity and its fields.
49///
50/// Constructed via a builder API with method chaining:
51///
52/// ```
53/// use ferro_projections::{ServiceDef, DataType, FieldMeaning};
54///
55/// let order = ServiceDef::new("order")
56///     .display_name("Order")
57///     .description("Manages customer orders")
58///     .field("id", DataType::Integer, FieldMeaning::Identifier)
59///     .field("total", DataType::Float, FieldMeaning::Money)
60///     .optional_field("notes", DataType::String, FieldMeaning::FreeText);
61/// ```
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
63pub struct ServiceDef {
64    pub name: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub display_name: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub description: Option<String>,
69    pub fields: Vec<FieldDef>,
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub actions: Vec<ActionDef>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub guards: Vec<GuardDef>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub relationships: Vec<RelationshipDef>,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub intent_hints: Vec<IntentHint>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub state_machine: Option<StateMachine>,
80    /// Whether this projection is exposed as an MCP tool.
81    /// Defaults to `false`. Only projections with `mcp_exposed: true`
82    /// appear in a `tools/list` response.
83    #[serde(default)]
84    pub mcp_exposed: bool,
85    /// FK column name used to scope reads to a tenant.
86    /// Plain metadata read by ferro-mcp-server dispatch; no auth dependency here.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub tenant_column: Option<String>,
89    /// Gate ability required to call this projection via MCP.
90    /// Plain metadata read by the app's MCP handler; no auth dependency here.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub mcp_ability: Option<String>,
93    /// Whether `create_<svc>` is derived for this projection (Track A).
94    /// Enabling any CRUD write verb requires `mcp_write_ability` (see `validate`).
95    #[serde(default)]
96    pub creatable: bool,
97    /// Whether `update_<svc>` (data-field patch) is derived for this projection.
98    #[serde(default)]
99    pub updatable: bool,
100    /// Whether `delete_<svc>` (soft-delete, confirmation-gated) is derived.
101    #[serde(default)]
102    pub deletable: bool,
103    /// Gate ability required to call the create/update/delete tools.
104    /// Required when any of `creatable`/`updatable`/`deletable` is set.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub mcp_write_ability: Option<String>,
107    /// Backing table name for derived CRUD dispatch (field→column binding).
108    /// When `None`, the dispatch layer derives it from the service name.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub table: Option<String>,
111    /// Soft-delete column name. When `None`, the dispatch layer defaults to `deleted_at`.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub soft_delete_column: Option<String>,
114}
115
116impl ServiceDef {
117    /// Creates a new service definition with the given name.
118    pub fn new(name: impl Into<String>) -> Self {
119        Self {
120            name: name.into(),
121            display_name: None,
122            description: None,
123            fields: Vec::new(),
124            actions: Vec::new(),
125            guards: Vec::new(),
126            relationships: Vec::new(),
127            intent_hints: Vec::new(),
128            state_machine: None,
129            mcp_exposed: false,
130            tenant_column: None,
131            mcp_ability: None,
132            creatable: false,
133            updatable: false,
134            deletable: false,
135            mcp_write_ability: None,
136            table: None,
137            soft_delete_column: None,
138        }
139    }
140
141    /// Sets the human-readable display name.
142    pub fn display_name(mut self, name: impl Into<String>) -> Self {
143        self.display_name = Some(name.into());
144        self
145    }
146
147    /// Sets the service description.
148    pub fn description(mut self, desc: impl Into<String>) -> Self {
149        self.description = Some(desc.into());
150        self
151    }
152
153    /// Marks this projection as MCP-exposed.
154    pub fn mcp_exposed(mut self, exposed: bool) -> Self {
155        self.mcp_exposed = exposed;
156        self
157    }
158
159    /// Declares the FK column name used to scope reads to a tenant.
160    /// Plain metadata read by ferro-mcp-server dispatch; no auth dependency here.
161    pub fn tenant_column(mut self, col: impl Into<String>) -> Self {
162        self.tenant_column = Some(col.into());
163        self
164    }
165
166    /// Declares the Gate ability required to call this projection via MCP.
167    /// Plain metadata read by the app's MCP handler; no auth dependency here.
168    pub fn mcp_ability(mut self, ability: impl Into<String>) -> Self {
169        self.mcp_ability = Some(ability.into());
170        self
171    }
172
173    /// Enables derivation of `create_<svc>`. Requires `mcp_write_ability` (Track A).
174    pub fn creatable(mut self, yes: bool) -> Self {
175        self.creatable = yes;
176        self
177    }
178
179    /// Enables derivation of `update_<svc>` (data-field patch). Requires `mcp_write_ability`.
180    pub fn updatable(mut self, yes: bool) -> Self {
181        self.updatable = yes;
182        self
183    }
184
185    /// Enables derivation of `delete_<svc>` (soft-delete, confirmation-gated).
186    /// Requires `mcp_write_ability`.
187    pub fn deletable(mut self, yes: bool) -> Self {
188        self.deletable = yes;
189        self
190    }
191
192    /// Declares the Gate ability required to call create/update/delete tools.
193    pub fn mcp_write_ability(mut self, ability: impl Into<String>) -> Self {
194        self.mcp_write_ability = Some(ability.into());
195        self
196    }
197
198    /// Declares the backing table for derived CRUD dispatch (field→column binding).
199    pub fn table(mut self, table: impl Into<String>) -> Self {
200        self.table = Some(table.into());
201        self
202    }
203
204    /// Declares the soft-delete column (defaults to `deleted_at` when unset).
205    pub fn soft_delete_column(mut self, col: impl Into<String>) -> Self {
206        self.soft_delete_column = Some(col.into());
207        self
208    }
209
210    /// Returns the backing table name: explicit `.table()` value or the
211    /// default `format!("{}s", name.to_lowercase())`.
212    ///
213    /// Matches the inline derivation previously at dispatch.rs:123 — the default
214    /// MUST stay byte-identical or existing projections query the wrong table.
215    pub fn resolved_table(&self) -> String {
216        self.table
217            .clone()
218            .unwrap_or_else(|| format!("{}s", self.name.to_lowercase()))
219    }
220
221    /// Returns the soft-delete column name: explicit `.soft_delete_column()` value
222    /// or the default `"deleted_at"`.
223    pub fn resolved_soft_delete_column(&self) -> &str {
224        self.soft_delete_column.as_deref().unwrap_or("deleted_at")
225    }
226
227    /// Returns true if the field must be server-injected and never an agent input.
228    ///
229    /// Covers:
230    /// - Identifier fields (primary key — set by DB auto-increment)
231    /// - CreatedAt fields (set by DB DEFAULT current_timestamp)
232    /// - The tenant column (injected from McpContext, never from agent payload)
233    ///
234    /// This is the schema-derivation boundary Phase 240 consumes to exclude these
235    /// fields from derived write input schemas (T-239-01 mitigation substrate).
236    pub fn is_server_injected_field(&self, field: &FieldDef) -> bool {
237        matches!(
238            field.meaning,
239            FieldMeaning::Identifier | FieldMeaning::CreatedAt
240        ) || self
241            .tenant_column
242            .as_deref()
243            .map(|tc| tc == field.name)
244            .unwrap_or(false)
245    }
246
247    /// Returns `true` if a field must be excluded from write input schemas
248    /// (create and update). Composes [`Self::is_server_injected_field`] and adds
249    /// UpdatedAt, Sensitive, and list-field exclusions.
250    ///
251    /// `exclude_sm_status`: callers pass `self.state_machine.is_some()`; when `true`,
252    /// a `Status` field is also excluded because the StateMachine sets it server-side
253    /// to the initial state.
254    pub fn is_write_excluded_field(&self, field: &FieldDef, exclude_sm_status: bool) -> bool {
255        // Gate A: server-injected — Identifier, CreatedAt, tenant column (Phase 239)
256        if self.is_server_injected_field(field) {
257            return true;
258        }
259        // Gate B: UpdatedAt — server-managed timestamp (D-05)
260        if matches!(field.meaning, FieldMeaning::UpdatedAt) {
261            return true;
262        }
263        // Gate C: Sensitive — never an agent write input (D-03)
264        if matches!(field.meaning, FieldMeaning::Sensitive) {
265            return true;
266        }
267        // Gate D: list fields — not useful as scalar write inputs (D-03)
268        if field.is_list {
269            return true;
270        }
271        // Gate E: Status under a StateMachine — set server-side (D-04/D-07)
272        if exclude_sm_status && matches!(field.meaning, FieldMeaning::Status) {
273            return true;
274        }
275        // Gate F: read-only field — declared non-writable (e.g. read_only_field for a
276        // derived/computed value like an order total). Never an agent write input;
277        // the `writable` flag is the single source of truth for write eligibility.
278        if !field.writable {
279            return true;
280        }
281        false
282    }
283
284    /// Adds a required read-write field.
285    pub fn field(
286        mut self,
287        name: impl Into<String>,
288        data_type: DataType,
289        meaning: FieldMeaning,
290    ) -> Self {
291        self.fields.push(FieldDef {
292            name: name.into(),
293            data_type,
294            meaning,
295            required: true,
296            is_list: false,
297            readable: true,
298            writable: true,
299            render_hint: None,
300        });
301        self
302    }
303
304    /// Adds a required read-write field carrying a non-visual [`RenderHint`].
305    ///
306    /// Use for `Url`/`ImageUrl` fields whose raw value has no useful text form:
307    /// `RenderHint::AltText(s)` substitutes `s`, `RenderHint::Skip` omits the
308    /// field from non-visual output. The visual renderer ignores the hint.
309    pub fn field_with_hint(
310        mut self,
311        name: impl Into<String>,
312        data_type: DataType,
313        meaning: FieldMeaning,
314        hint: RenderHint,
315    ) -> Self {
316        self.fields.push(FieldDef {
317            name: name.into(),
318            data_type,
319            meaning,
320            required: true,
321            is_list: false,
322            readable: true,
323            writable: true,
324            render_hint: Some(hint),
325        });
326        self
327    }
328
329    /// Adds an optional (nullable) read-write field.
330    pub fn optional_field(
331        mut self,
332        name: impl Into<String>,
333        data_type: DataType,
334        meaning: FieldMeaning,
335    ) -> Self {
336        self.fields.push(FieldDef {
337            name: name.into(),
338            data_type,
339            meaning,
340            required: false,
341            is_list: false,
342            readable: true,
343            writable: true,
344            render_hint: None,
345        });
346        self
347    }
348
349    /// Adds a required read-write list field.
350    pub fn list_field(
351        mut self,
352        name: impl Into<String>,
353        data_type: DataType,
354        meaning: FieldMeaning,
355    ) -> Self {
356        self.fields.push(FieldDef {
357            name: name.into(),
358            data_type,
359            meaning,
360            required: true,
361            is_list: true,
362            readable: true,
363            writable: true,
364            render_hint: None,
365        });
366        self
367    }
368
369    /// Adds a required read-only field (readable but not writable).
370    ///
371    /// For system-assigned or computed fields like id, created_at, or totals.
372    pub fn read_only_field(
373        mut self,
374        name: impl Into<String>,
375        data_type: DataType,
376        meaning: FieldMeaning,
377    ) -> Self {
378        self.fields.push(FieldDef {
379            name: name.into(),
380            data_type,
381            meaning,
382            required: true,
383            is_list: false,
384            readable: true,
385            writable: false,
386            render_hint: None,
387        });
388        self
389    }
390
391    /// Adds a required write-only field (writable but not readable).
392    ///
393    /// For sensitive inputs like passwords or API keys that should not be read back.
394    pub fn write_only_field(
395        mut self,
396        name: impl Into<String>,
397        data_type: DataType,
398        meaning: FieldMeaning,
399    ) -> Self {
400        self.fields.push(FieldDef {
401            name: name.into(),
402            data_type,
403            meaning,
404            required: true,
405            is_list: false,
406            readable: false,
407            writable: true,
408            render_hint: None,
409        });
410        self
411    }
412
413    /// Adds an action definition to this service.
414    pub fn action(mut self, action: ActionDef) -> Self {
415        self.actions.push(action);
416        self
417    }
418
419    /// Adds a guard definition to this service.
420    pub fn guard(mut self, guard: GuardDef) -> Self {
421        self.guards.push(guard);
422        self
423    }
424
425    /// Adds a relationship definition to this service.
426    pub fn relationship(mut self, rel: RelationshipDef) -> Self {
427        self.relationships.push(rel);
428        self
429    }
430
431    /// Adds a many-to-one relationship (this service belongs to target).
432    pub fn belongs_to(self, name: impl Into<String>, target: impl Into<String>) -> Self {
433        self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToOne))
434    }
435
436    /// Adds a one-to-many relationship (this service has many of target).
437    pub fn has_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
438        self.relationship(RelationshipDef::new(name, target, Cardinality::OneToMany))
439    }
440
441    /// Adds a one-to-one relationship (this service has one of target).
442    pub fn has_one(self, name: impl Into<String>, target: impl Into<String>) -> Self {
443        self.relationship(RelationshipDef::new(name, target, Cardinality::OneToOne))
444    }
445
446    /// Adds a many-to-many relationship (this service belongs to many of target).
447    pub fn belongs_to_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
448        self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToMany))
449    }
450
451    /// Adds an intent hint for overriding structural derivation.
452    pub fn intent_hint(mut self, hint: IntentHint) -> Self {
453        self.intent_hints.push(hint);
454        self
455    }
456
457    /// Sets the state machine definition for this service.
458    pub fn state_machine(mut self, machine: StateMachine) -> Self {
459        self.state_machine = Some(machine);
460        self
461    }
462
463    /// Derives a ServiceDef from model metadata.
464    ///
465    /// Infers `DataType` from column type strings and `FieldMeaning` from field names.
466    /// System fields (`id`, `created_at`, `updated_at`, primary keys) are marked read-only.
467    /// Actions, state machines, and relationships are not derived.
468    pub fn from_model(meta: &ModelMetadata) -> Self {
469        let display = meta
470            .display_name
471            .clone()
472            .unwrap_or_else(|| snake_to_title(&meta.name));
473
474        let mut def = Self::new(&meta.name).display_name(display);
475
476        for field in &meta.fields {
477            let data_type = DataType::from_column_type(&field.column_type);
478            let meaning = infer_meaning(&field.name);
479
480            let is_system = matches!(field.name.as_str(), "id" | "created_at" | "updated_at")
481                || field.is_primary_key;
482
483            def.fields.push(FieldDef {
484                name: field.name.clone(),
485                data_type,
486                meaning,
487                required: !field.is_nullable,
488                is_list: false,
489                readable: true,
490                writable: !is_system,
491                render_hint: None,
492            });
493        }
494
495        def
496    }
497
498    /// Validates the service definition and returns warnings for potential issues.
499    ///
500    /// This is the single validation entry point that subsumes `StateMachine::validate()`.
501    /// Guard names form a shared pool referenced from transitions and action preconditions.
502    ///
503    /// Returns `Err` for fatal issues (undefined guard references, unmatched triggers).
504    /// Returns `Ok(warnings)` for structural concerns (unused guards, missing state machine).
505    pub fn validate(&self) -> Result<Vec<Warning>, crate::Error> {
506        let mut warnings = Vec::new();
507
508        // 0. Track A: enabling any CRUD write verb requires a declared write ability.
509        // Fail-fast at registration rather than silently denying at call time.
510        if (self.creatable || self.updatable || self.deletable) && self.mcp_write_ability.is_none()
511        {
512            return Err(crate::Error::Validation(format!(
513                "projection '{}' enables create/update/delete but declares no mcp_write_ability",
514                self.name
515            )));
516        }
517
518        // 1. Delegate to state machine validation if present
519        if let Some(ref sm) = self.state_machine {
520            warnings.extend(sm.validate()?);
521        }
522
523        // 2. Collect declared guard names
524        let declared_guards: HashSet<&str> = self.guards.iter().map(|g| g.name.as_str()).collect();
525
526        // 3. Check action preconditions reference declared guards
527        for action in &self.actions {
528            for precondition in &action.preconditions {
529                if !declared_guards.contains(precondition.as_str()) {
530                    return Err(crate::Error::Validation(format!(
531                        "action '{}' references undefined guard '{}'",
532                        action.name, precondition
533                    )));
534                }
535            }
536        }
537
538        // 4. Check transition guards reference declared guards (if state machine exists)
539        if let Some(ref sm) = self.state_machine {
540            for transition in &sm.transitions {
541                if let Some(ref guard) = transition.guard {
542                    if !declared_guards.contains(guard.as_str()) {
543                        return Err(crate::Error::Validation(format!(
544                            "transition '{}' -> '{}' references undefined guard '{}'",
545                            transition.from, transition.to, guard
546                        )));
547                    }
548                }
549            }
550        }
551
552        // 5. Check action transition_triggers match state machine event names
553        if let Some(ref sm) = self.state_machine {
554            let event_names: HashSet<&str> =
555                sm.transitions.iter().map(|t| t.event.as_str()).collect();
556            for action in &self.actions {
557                if let Some(ref trigger) = action.transition_trigger {
558                    if !event_names.contains(trigger.as_str()) {
559                        return Err(crate::Error::Validation(format!(
560                            "action '{}' has transition_trigger '{}' that does not match any state machine event",
561                            action.name, trigger
562                        )));
563                    }
564                }
565            }
566        }
567
568        // 5b. Sync-by-construction gate: every transition-triggering action must
569        // produce a TransitionPlan via the same derivation the runtime uses. This
570        // round-trip guarantees `validate()` accepting an action implies the
571        // derivation can build a plan for it — drift between the two is structurally
572        // impossible (EXEC-04). Surfaces AmbiguousTransition at registration too.
573        if self.state_machine.is_some() {
574            for action in &self.actions {
575                if action.transition_trigger.is_some() {
576                    crate::executor::derive_transition_plan(self, &action.name)?;
577                }
578            }
579        }
580
581        // 6. Warn about declared guards never referenced
582        let mut referenced_guards: HashSet<&str> = HashSet::new();
583        for action in &self.actions {
584            for precondition in &action.preconditions {
585                referenced_guards.insert(precondition.as_str());
586            }
587        }
588        if let Some(ref sm) = self.state_machine {
589            for transition in &sm.transitions {
590                if let Some(ref guard) = transition.guard {
591                    referenced_guards.insert(guard.as_str());
592                }
593            }
594        }
595        for guard in &self.guards {
596            if !referenced_guards.contains(guard.name.as_str()) {
597                warnings.push(Warning::UnusedGuard(guard.name.clone()));
598            }
599        }
600
601        // 7. Warn about actions with transition_trigger when no state machine exists
602        if self.state_machine.is_none() {
603            for action in &self.actions {
604                if action.transition_trigger.is_some() {
605                    warnings.push(Warning::TransitionTriggerWithoutStateMachine(
606                        action.name.clone(),
607                    ));
608                }
609            }
610        }
611
612        // 8. Warn about duplicate relationship names
613        {
614            let mut seen = HashSet::new();
615            for rel in &self.relationships {
616                if !seen.insert(rel.name.as_str()) {
617                    warnings.push(Warning::DuplicateRelationship(rel.name.clone()));
618                }
619            }
620        }
621
622        // 9. Warn if ManyToMany relationship has foreign_key set
623        for rel in &self.relationships {
624            if rel.cardinality == Cardinality::ManyToMany && rel.foreign_key.is_some() {
625                warnings.push(Warning::ManyToManyWithForeignKey {
626                    relationship: rel.name.clone(),
627                });
628            }
629        }
630
631        // 10. Check for conflicting intent hints (same intent in both Primary and Exclude)
632        {
633            let mut primaries = HashSet::new();
634            let mut excludes = HashSet::new();
635            let mut primary_count = 0u32;
636
637            for hint in &self.intent_hints {
638                match hint {
639                    IntentHint::Primary(intent) => {
640                        primary_count += 1;
641                        let serialized = serde_json::to_string(intent)
642                            .unwrap_or_default()
643                            .trim_matches('"')
644                            .to_string();
645                        primaries.insert(serialized);
646                    }
647                    IntentHint::Exclude(intent) => {
648                        let serialized = serde_json::to_string(intent)
649                            .unwrap_or_default()
650                            .trim_matches('"')
651                            .to_string();
652                        excludes.insert(serialized);
653                    }
654                }
655            }
656
657            for intent_name in primaries.intersection(&excludes) {
658                warnings.push(Warning::ConflictingIntentHints {
659                    intent: intent_name.clone(),
660                });
661            }
662
663            if primary_count > 1 {
664                warnings.push(Warning::MultiplePrimaryIntentHints);
665            }
666        }
667
668        Ok(warnings)
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn service_def_builder_chain() {
678        let service = ServiceDef::new("order")
679            .display_name("Order")
680            .description("Manages customer orders")
681            .field("id", DataType::Integer, FieldMeaning::Identifier)
682            .field("total", DataType::Float, FieldMeaning::Money)
683            .field("status", DataType::String, FieldMeaning::Status)
684            .optional_field("notes", DataType::String, FieldMeaning::FreeText)
685            .list_field("tags", DataType::String, FieldMeaning::Category);
686
687        assert_eq!(service.name, "order");
688        assert_eq!(service.display_name.as_deref(), Some("Order"));
689        assert_eq!(
690            service.description.as_deref(),
691            Some("Manages customer orders")
692        );
693        assert_eq!(service.fields.len(), 5);
694
695        // Required field
696        assert!(service.fields[0].required);
697        assert!(!service.fields[0].is_list);
698
699        // Optional field
700        assert!(!service.fields[3].required);
701        assert!(!service.fields[3].is_list);
702
703        // List field
704        assert!(service.fields[4].required);
705        assert!(service.fields[4].is_list);
706    }
707
708    #[test]
709    fn service_def_minimal() {
710        let service = ServiceDef::new("user");
711        assert_eq!(service.name, "user");
712        assert!(service.display_name.is_none());
713        assert!(service.description.is_none());
714        assert!(service.fields.is_empty());
715    }
716
717    #[test]
718    fn service_def_serde_round_trip() {
719        let service = ServiceDef::new("order")
720            .display_name("Order")
721            .field("id", DataType::Integer, FieldMeaning::Identifier)
722            .field("total", DataType::Float, FieldMeaning::Money)
723            .optional_field("notes", DataType::String, FieldMeaning::FreeText);
724
725        let json = serde_json::to_string(&service).unwrap();
726        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
727        assert_eq!(service, parsed);
728    }
729
730    #[test]
731    fn service_def_json_omits_none_fields() {
732        let service = ServiceDef::new("order");
733        let json = serde_json::to_string(&service).unwrap();
734        assert!(!json.contains("display_name"));
735        assert!(!json.contains("description"));
736    }
737
738    #[test]
739    fn service_def_multiple_fields() {
740        let service = ServiceDef::new("product")
741            .field("id", DataType::Integer, FieldMeaning::Identifier)
742            .field("name", DataType::String, FieldMeaning::EntityName)
743            .field("price", DataType::Float, FieldMeaning::Money)
744            .field("sku", DataType::String, FieldMeaning::Custom("sku".into()))
745            .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
746
747        assert_eq!(service.fields.len(), 5);
748        // Order preserved
749        assert_eq!(service.fields[0].name, "id");
750        assert_eq!(service.fields[1].name, "name");
751        assert_eq!(service.fields[2].name, "price");
752        assert_eq!(service.fields[3].name, "sku");
753        assert_eq!(service.fields[4].name, "created_at");
754    }
755
756    #[test]
757    fn field_with_hint_attaches_render_hint() {
758        let service = ServiceDef::new("profile")
759            .field("id", DataType::Integer, FieldMeaning::Identifier)
760            .field_with_hint(
761                "avatar",
762                DataType::String,
763                FieldMeaning::ImageUrl,
764                RenderHint::AltText("User avatar".into()),
765            );
766
767        assert_eq!(service.fields[0].render_hint, None);
768        assert_eq!(
769            service.fields[1].render_hint,
770            Some(RenderHint::AltText("User avatar".into()))
771        );
772        // Mirrors `.field()`: required, read-write, not a list.
773        assert!(service.fields[1].required);
774        assert!(service.fields[1].readable);
775        assert!(service.fields[1].writable);
776        assert!(!service.fields[1].is_list);
777    }
778
779    #[test]
780    fn service_def_json_structure() {
781        let service = ServiceDef::new("order")
782            .display_name("Order")
783            .description("Customer orders")
784            .field("id", DataType::Integer, FieldMeaning::Identifier)
785            .optional_field("notes", DataType::String, FieldMeaning::FreeText);
786
787        let json = serde_json::to_string(&service).unwrap();
788        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
789
790        assert!(value.get("name").is_some());
791        assert!(value.get("display_name").is_some());
792        assert!(value.get("description").is_some());
793        assert!(value.get("fields").is_some());
794
795        let fields = value["fields"].as_array().unwrap();
796        assert_eq!(fields.len(), 2);
797    }
798
799    #[test]
800    fn order_service_example() {
801        let service = ServiceDef::new("order")
802            .display_name("Order")
803            .description("Manages customer orders and fulfillment")
804            .field("id", DataType::Integer, FieldMeaning::Identifier)
805            .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
806            .field("total", DataType::Float, FieldMeaning::Money)
807            .field("status", DataType::String, FieldMeaning::Status)
808            .field("email", DataType::String, FieldMeaning::Email)
809            .field("notes", DataType::String, FieldMeaning::FreeText)
810            .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
811            .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt);
812
813        assert_eq!(service.fields.len(), 8);
814        assert_eq!(service.fields[2].meaning, FieldMeaning::Money);
815        assert_eq!(service.fields[3].meaning, FieldMeaning::Status);
816
817        // Serde round-trip
818        let json = serde_json::to_string(&service).unwrap();
819        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
820        assert_eq!(service, parsed);
821    }
822
823    // -- StateMachine integration tests --
824
825    use crate::state::{StateDef, StateMachine, Transition};
826
827    #[test]
828    fn service_def_with_state_machine() {
829        let machine = StateMachine::new("order_lifecycle")
830            .initial("draft")
831            .state(StateDef::new("draft"))
832            .state(StateDef::new("completed").final_state())
833            .transition(Transition::new("draft", "complete", "completed"));
834
835        let service = ServiceDef::new("order")
836            .field("id", DataType::Integer, FieldMeaning::Identifier)
837            .state_machine(machine);
838
839        assert!(service.state_machine.is_some());
840        let sm = service.state_machine.as_ref().unwrap();
841        assert_eq!(sm.states.len(), 2);
842        assert_eq!(sm.transitions.len(), 1);
843    }
844
845    #[test]
846    fn service_def_state_machine_serde_round_trip() {
847        let machine = StateMachine::new("order_lifecycle")
848            .initial("draft")
849            .state(StateDef::new("draft").display_name("Draft"))
850            .state(
851                StateDef::new("completed")
852                    .display_name("Completed")
853                    .final_state(),
854            )
855            .transition(
856                Transition::new("draft", "complete", "completed")
857                    .guard("is_valid")
858                    .actions(vec!["notify"]),
859            );
860
861        let service = ServiceDef::new("order")
862            .display_name("Order")
863            .field("id", DataType::Integer, FieldMeaning::Identifier)
864            .field("status", DataType::String, FieldMeaning::Status)
865            .state_machine(machine);
866
867        let json = serde_json::to_string_pretty(&service).unwrap();
868        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
869        assert_eq!(service, parsed);
870    }
871
872    #[test]
873    fn service_def_without_state_machine_json() {
874        let service =
875            ServiceDef::new("user").field("id", DataType::Integer, FieldMeaning::Identifier);
876
877        let json = serde_json::to_string(&service).unwrap();
878        assert!(!json.contains("state_machine"));
879    }
880
881    #[test]
882    fn order_service_full_example() {
883        let machine = StateMachine::new("order_lifecycle")
884            .display_name("Order Lifecycle")
885            .description("Tracks an order from creation to fulfillment")
886            .initial("draft")
887            .state(
888                StateDef::new("draft")
889                    .display_name("Draft")
890                    .description("Order is being prepared"),
891            )
892            .state(
893                StateDef::new("submitted")
894                    .display_name("Submitted")
895                    .on_enter(vec!["validate_inventory", "calculate_totals"]),
896            )
897            .state(
898                StateDef::new("processing")
899                    .display_name("Processing")
900                    .on_enter(vec!["charge_payment", "reserve_inventory"]),
901            )
902            .state(
903                StateDef::new("shipped")
904                    .display_name("Shipped")
905                    .on_enter(vec!["generate_tracking", "notify_customer"]),
906            )
907            .state(
908                StateDef::new("delivered")
909                    .display_name("Delivered")
910                    .final_state(),
911            )
912            .state(
913                StateDef::new("cancelled")
914                    .display_name("Cancelled")
915                    .final_state()
916                    .on_enter(vec!["refund_payment", "release_inventory"]),
917            )
918            .transition(
919                Transition::new("draft", "submit", "submitted")
920                    .guard("has_items")
921                    .description("Customer submits the order"),
922            )
923            .transition(
924                Transition::new("submitted", "process", "processing")
925                    .guard("payment_valid")
926                    .actions(vec!["lock_prices"]),
927            )
928            .transition(
929                Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
930            )
931            .transition(Transition::new("shipped", "deliver", "delivered"))
932            .transition(Transition::new("draft", "cancel", "cancelled"))
933            .transition(
934                Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
935            )
936            .transition(
937                Transition::new("processing", "cancel", "cancelled")
938                    .guard("cancellation_allowed")
939                    .actions(vec!["reverse_payment"]),
940            );
941
942        let service = ServiceDef::new("order")
943            .display_name("Order")
944            .description("Manages customer orders and fulfillment")
945            .field("id", DataType::Integer, FieldMeaning::Identifier)
946            .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
947            .field("total", DataType::Float, FieldMeaning::Money)
948            .field("status", DataType::String, FieldMeaning::Status)
949            .field("email", DataType::String, FieldMeaning::Email)
950            .field("notes", DataType::String, FieldMeaning::FreeText)
951            .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
952            .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt)
953            .state_machine(machine);
954
955        // Field assertions
956        assert_eq!(service.fields.len(), 8);
957
958        // State machine assertions
959        let sm = service.state_machine.as_ref().unwrap();
960        assert_eq!(sm.states.len(), 6);
961        assert_eq!(sm.transitions.len(), 7);
962        assert_eq!(sm.initial_state, "draft");
963
964        // Validation passes cleanly
965        let warnings = sm.validate().unwrap();
966        assert!(warnings.is_empty());
967
968        // Serde round-trip
969        let json = serde_json::to_string_pretty(&service).unwrap();
970        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
971        assert_eq!(service, parsed);
972    }
973
974    #[test]
975    fn service_def_json_schema() {
976        let schema = schemars::schema_for!(ServiceDef);
977        let value = schema.to_value();
978        let props = value
979            .get("properties")
980            .expect("ServiceDef schema must have properties");
981        let obj = props.as_object().unwrap();
982        assert!(obj.contains_key("name"), "missing 'name' property");
983        assert!(obj.contains_key("fields"), "missing 'fields' property");
984        assert!(
985            obj.contains_key("state_machine"),
986            "missing 'state_machine' property"
987        );
988    }
989
990    // -- readable/writable builder tests --
991
992    #[test]
993    fn read_only_field_builder() {
994        let service = ServiceDef::new("order")
995            .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
996            .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
997
998        assert_eq!(service.fields.len(), 2);
999        for f in &service.fields {
1000            assert!(f.readable);
1001            assert!(!f.writable);
1002            assert!(f.required);
1003            assert!(!f.is_list);
1004        }
1005    }
1006
1007    #[test]
1008    fn write_only_field_builder() {
1009        let service = ServiceDef::new("user").write_only_field(
1010            "password",
1011            DataType::String,
1012            FieldMeaning::Sensitive,
1013        );
1014
1015        assert_eq!(service.fields.len(), 1);
1016        let f = &service.fields[0];
1017        assert!(!f.readable);
1018        assert!(f.writable);
1019        assert!(f.required);
1020        assert!(!f.is_list);
1021    }
1022
1023    #[test]
1024    fn mixed_access_fields_serde_round_trip() {
1025        let service = ServiceDef::new("user")
1026            .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1027            .field("name", DataType::String, FieldMeaning::EntityName)
1028            .write_only_field("password", DataType::String, FieldMeaning::Sensitive)
1029            .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
1030
1031        let json = serde_json::to_string(&service).unwrap();
1032        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1033        assert_eq!(service, parsed);
1034
1035        // Verify access modes survived round-trip
1036        assert!(parsed.fields[0].readable);
1037        assert!(!parsed.fields[0].writable);
1038        assert!(parsed.fields[1].readable);
1039        assert!(parsed.fields[1].writable);
1040        assert!(!parsed.fields[2].readable);
1041        assert!(parsed.fields[2].writable);
1042        assert!(parsed.fields[3].readable);
1043        assert!(!parsed.fields[3].writable);
1044    }
1045
1046    #[test]
1047    fn existing_field_builders_default_read_write() {
1048        let service = ServiceDef::new("order")
1049            .field("id", DataType::Integer, FieldMeaning::Identifier)
1050            .optional_field("notes", DataType::String, FieldMeaning::FreeText)
1051            .list_field("tags", DataType::String, FieldMeaning::Category);
1052
1053        for f in &service.fields {
1054            assert!(f.readable, "field '{}' should be readable", f.name);
1055            assert!(f.writable, "field '{}' should be writable", f.name);
1056        }
1057    }
1058
1059    // -- Phase 86-02 tests: actions/guards integration + validate() --
1060
1061    use crate::action::{ActionDef, GuardDef, InputDef};
1062    use crate::state::Warning;
1063
1064    #[test]
1065    fn service_def_with_actions_and_guards_builder() {
1066        let service = ServiceDef::new("order")
1067            .guard(GuardDef::new("has_items"))
1068            .guard(GuardDef::new("payment_valid"))
1069            .action(
1070                ActionDef::new("submit_order")
1071                    .precondition("has_items")
1072                    .precondition("payment_valid"),
1073            )
1074            .action(ActionDef::new("update_notes"));
1075
1076        assert_eq!(service.guards.len(), 2);
1077        assert_eq!(service.actions.len(), 2);
1078        assert_eq!(service.actions[0].name, "submit_order");
1079        assert_eq!(service.actions[1].name, "update_notes");
1080    }
1081
1082    #[test]
1083    fn service_def_serde_round_trip_with_actions_guards() {
1084        let service = ServiceDef::new("order")
1085            .field("id", DataType::Integer, FieldMeaning::Identifier)
1086            .guard(GuardDef::new("has_items").display_name("Has Items"))
1087            .action(
1088                ActionDef::new("submit")
1089                    .input(InputDef::new(
1090                        "order_id",
1091                        DataType::Integer,
1092                        FieldMeaning::Identifier,
1093                    ))
1094                    .precondition("has_items")
1095                    .effect("notify"),
1096            );
1097
1098        let json = serde_json::to_string_pretty(&service).unwrap();
1099        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1100        assert_eq!(service, parsed);
1101    }
1102
1103    #[test]
1104    fn service_def_json_omits_empty_actions_guards() {
1105        let service = ServiceDef::new("user");
1106        let json = serde_json::to_string(&service).unwrap();
1107        assert!(!json.contains("actions"));
1108        assert!(!json.contains("guards"));
1109    }
1110
1111    #[test]
1112    fn validate_passes_valid_service() {
1113        let machine = StateMachine::new("order_lifecycle")
1114            .initial("draft")
1115            .state(StateDef::new("draft"))
1116            .state(StateDef::new("submitted").final_state())
1117            .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
1118
1119        let service = ServiceDef::new("order")
1120            .field("id", DataType::Integer, FieldMeaning::Identifier)
1121            .guard(GuardDef::new("has_items"))
1122            .action(
1123                ActionDef::new("submit_order")
1124                    .precondition("has_items")
1125                    .transition_trigger("submit"),
1126            )
1127            .state_machine(machine);
1128
1129        let warnings = service.validate().unwrap();
1130        assert!(warnings.is_empty());
1131    }
1132
1133    #[test]
1134    fn validate_catches_undefined_action_precondition() {
1135        let service = ServiceDef::new("order")
1136            .guard(GuardDef::new("has_items"))
1137            .action(ActionDef::new("submit").precondition("nonexistent_guard"));
1138
1139        let result = service.validate();
1140        assert!(result.is_err());
1141        let err = result.unwrap_err().to_string();
1142        assert!(err.contains("nonexistent_guard"));
1143        assert!(err.contains("submit"));
1144    }
1145
1146    #[test]
1147    fn validate_catches_undefined_transition_guard() {
1148        let machine = StateMachine::new("lifecycle")
1149            .initial("draft")
1150            .state(StateDef::new("draft"))
1151            .state(StateDef::new("done").final_state())
1152            .transition(Transition::new("draft", "finish", "done").guard("undefined_guard"));
1153
1154        let service = ServiceDef::new("order").state_machine(machine);
1155
1156        let result = service.validate();
1157        assert!(result.is_err());
1158        let err = result.unwrap_err().to_string();
1159        assert!(err.contains("undefined_guard"));
1160    }
1161
1162    #[test]
1163    fn validate_catches_unmatched_transition_trigger() {
1164        let machine = StateMachine::new("lifecycle")
1165            .initial("draft")
1166            .state(StateDef::new("draft"))
1167            .state(StateDef::new("done").final_state())
1168            .transition(Transition::new("draft", "finish", "done"));
1169
1170        let service = ServiceDef::new("order")
1171            .action(ActionDef::new("submit").transition_trigger("nonexistent_event"))
1172            .state_machine(machine);
1173
1174        let result = service.validate();
1175        assert!(result.is_err());
1176        let err = result.unwrap_err().to_string();
1177        assert!(err.contains("nonexistent_event"));
1178    }
1179
1180    /// A well-formed order service mirroring the EXEC-01 reference fixture,
1181    /// used by the EXEC-04 sync-by-construction tests below.
1182    fn well_formed_order_service() -> ServiceDef {
1183        let machine = StateMachine::new("order_lifecycle")
1184            .initial("draft")
1185            .state(StateDef::new("draft"))
1186            .state(StateDef::new("submitted"))
1187            .state(StateDef::new("approved"))
1188            .state(StateDef::new("cancelled").final_state())
1189            .state(StateDef::new("shipped"))
1190            .state(StateDef::new("delivered").final_state())
1191            .transition(Transition::new("draft", "submit", "submitted"))
1192            .transition(Transition::new("submitted", "approve", "approved").guard("is_manager"))
1193            .transition(Transition::new("approved", "ship", "shipped"))
1194            .transition(Transition::new("draft", "cancel", "cancelled"))
1195            .transition(Transition::new("submitted", "cancel", "cancelled"));
1196
1197        ServiceDef::new("order")
1198            .guard(GuardDef::new("is_manager"))
1199            .state_machine(machine)
1200            .action(ActionDef::new("submit").transition_trigger("submit"))
1201            .action(
1202                ActionDef::new("approve")
1203                    .transition_trigger("approve")
1204                    .precondition("is_manager"),
1205            )
1206            .action(ActionDef::new("ship").transition_trigger("ship"))
1207            .action(ActionDef::new("cancel").transition_trigger("cancel"))
1208    }
1209
1210    #[test]
1211    fn validate_rejects_undeclared_trigger() {
1212        let machine = StateMachine::new("lifecycle")
1213            .initial("draft")
1214            .state(StateDef::new("draft"))
1215            .state(StateDef::new("done").final_state())
1216            .transition(Transition::new("draft", "finish", "done"));
1217
1218        let service = ServiceDef::new("order")
1219            .state_machine(machine)
1220            .action(ActionDef::new("submit").transition_trigger("typo_event"));
1221
1222        let err = service.validate().unwrap_err();
1223        // Step 5 produces a Validation error naming the bad trigger; the
1224        // round-trip in step 5b is consistent with it (same fact).
1225        assert!(err.to_string().contains("typo_event"));
1226    }
1227
1228    #[test]
1229    fn validate_accepts_well_formed_order_service() {
1230        let service = well_formed_order_service();
1231        assert!(service.validate().is_ok());
1232    }
1233
1234    #[test]
1235    fn validate_round_trips_derivation() {
1236        let service = well_formed_order_service();
1237        // validate() accepts it...
1238        assert!(service.validate().is_ok());
1239        // ...and every transition-triggering action yields a plan, so the two
1240        // checks cannot diverge.
1241        for action in &service.actions {
1242            if action.transition_trigger.is_some() {
1243                assert!(
1244                    crate::executor::derive_transition_plan(&service, &action.name).is_ok(),
1245                    "derivation must succeed for action '{}'",
1246                    action.name
1247                );
1248            }
1249        }
1250    }
1251
1252    #[test]
1253    fn validate_rejects_ambiguous_fan_out_at_registration() {
1254        // A fan-out event now fails registration (step 5b), not first call.
1255        let machine = StateMachine::new("lifecycle")
1256            .initial("a")
1257            .state(StateDef::new("a"))
1258            .state(StateDef::new("b"))
1259            .state(StateDef::new("c"))
1260            .state(StateDef::new("d"))
1261            .transition(Transition::new("a", "split", "b"))
1262            .transition(Transition::new("c", "split", "d"));
1263
1264        let service = ServiceDef::new("order")
1265            .state_machine(machine)
1266            .action(ActionDef::new("split").transition_trigger("split"));
1267
1268        let err = service.validate().unwrap_err();
1269        assert!(matches!(err, crate::Error::AmbiguousTransition { ref event } if event == "split"));
1270    }
1271
1272    #[test]
1273    fn validate_warns_unused_guards() {
1274        let service = ServiceDef::new("order")
1275            .guard(GuardDef::new("used_guard"))
1276            .guard(GuardDef::new("unused_guard"))
1277            .action(ActionDef::new("submit").precondition("used_guard"));
1278
1279        let warnings = service.validate().unwrap();
1280        assert_eq!(warnings.len(), 1);
1281        assert!(warnings.contains(&Warning::UnusedGuard("unused_guard".into())));
1282    }
1283
1284    #[test]
1285    fn validate_warns_transition_trigger_without_state_machine() {
1286        let service =
1287            ServiceDef::new("order").action(ActionDef::new("submit").transition_trigger("submit"));
1288
1289        let warnings = service.validate().unwrap();
1290        assert_eq!(warnings.len(), 1);
1291        assert!(
1292            warnings.contains(&Warning::TransitionTriggerWithoutStateMachine(
1293                "submit".into()
1294            ))
1295        );
1296    }
1297
1298    #[test]
1299    fn validate_delegates_to_state_machine_validate() {
1300        // Missing initial state in states — state machine validation catches this
1301        let machine = StateMachine::new("lifecycle")
1302            .initial("nonexistent")
1303            .state(StateDef::new("a").final_state());
1304
1305        let service = ServiceDef::new("order").state_machine(machine);
1306
1307        let result = service.validate();
1308        assert!(result.is_err());
1309        let err = result.unwrap_err().to_string();
1310        assert!(err.contains("nonexistent"));
1311    }
1312
1313    #[test]
1314    fn validate_without_state_machine_or_actions_passes_clean() {
1315        let service =
1316            ServiceDef::new("simple").field("id", DataType::Integer, FieldMeaning::Identifier);
1317
1318        let warnings = service.validate().unwrap();
1319        assert!(warnings.is_empty());
1320    }
1321
1322    #[test]
1323    fn full_order_service_with_guards_actions_validates_clean() {
1324        let machine = StateMachine::new("order_lifecycle")
1325            .display_name("Order Lifecycle")
1326            .initial("draft")
1327            .state(StateDef::new("draft").display_name("Draft"))
1328            .state(StateDef::new("submitted").display_name("Submitted"))
1329            .state(StateDef::new("processing").display_name("Processing"))
1330            .state(
1331                StateDef::new("shipped")
1332                    .display_name("Shipped")
1333                    .final_state(),
1334            )
1335            .state(
1336                StateDef::new("cancelled")
1337                    .display_name("Cancelled")
1338                    .final_state(),
1339            )
1340            .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
1341            .transition(
1342                Transition::new("submitted", "process", "processing").guard("payment_valid"),
1343            )
1344            .transition(
1345                Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
1346            )
1347            .transition(
1348                Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1349            )
1350            .transition(
1351                Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1352            );
1353
1354        let service = ServiceDef::new("order")
1355            .display_name("Order")
1356            .description("Full order management")
1357            .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1358            .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1359            .field("total", DataType::Float, FieldMeaning::Money)
1360            .field("status", DataType::String, FieldMeaning::Status)
1361            .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1362            .guard(GuardDef::new("has_items").display_name("Has Items"))
1363            .guard(GuardDef::new("payment_valid").display_name("Payment Valid"))
1364            .guard(GuardDef::new("inventory_fulfilled").display_name("Inventory Fulfilled"))
1365            .guard(GuardDef::new("cancellation_allowed").display_name("Cancellation Allowed"))
1366            .action(
1367                ActionDef::new("submit_order")
1368                    .display_name("Submit Order")
1369                    .input(InputDef::new(
1370                        "order_id",
1371                        DataType::Integer,
1372                        FieldMeaning::Identifier,
1373                    ))
1374                    .precondition("has_items")
1375                    .effect("notify_customer")
1376                    .transition_trigger("submit"),
1377            )
1378            .action(
1379                ActionDef::new("process_order")
1380                    .precondition("payment_valid")
1381                    .transition_trigger("process"),
1382            )
1383            .action(
1384                ActionDef::new("ship_order")
1385                    .precondition("inventory_fulfilled")
1386                    .transition_trigger("ship"),
1387            )
1388            .action(
1389                ActionDef::new("cancel_order")
1390                    .precondition("cancellation_allowed")
1391                    .effect("refund_payment")
1392                    .transition_trigger("cancel"),
1393            )
1394            .state_machine(machine);
1395
1396        // Validate passes with no warnings
1397        let warnings = service.validate().unwrap();
1398        assert!(
1399            warnings.is_empty(),
1400            "expected no warnings, got: {warnings:?}"
1401        );
1402
1403        // All pieces present
1404        assert_eq!(service.fields.len(), 5);
1405        assert_eq!(service.guards.len(), 4);
1406        assert_eq!(service.actions.len(), 4);
1407        assert!(service.state_machine.is_some());
1408
1409        // Serde round-trip
1410        let json = serde_json::to_string_pretty(&service).unwrap();
1411        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1412        assert_eq!(service, parsed);
1413    }
1414
1415    #[test]
1416    fn service_def_json_schema_includes_actions_guards() {
1417        let schema = schemars::schema_for!(ServiceDef);
1418        let value = schema.to_value();
1419        let props = value
1420            .get("properties")
1421            .expect("ServiceDef schema must have properties");
1422        let obj = props.as_object().unwrap();
1423        assert!(obj.contains_key("actions"), "missing 'actions' property");
1424        assert!(obj.contains_key("guards"), "missing 'guards' property");
1425    }
1426
1427    // -- Phase 87-01 tests: relationships --
1428
1429    use crate::relationship::{Cardinality, NavigationHint, RelationshipDef};
1430
1431    #[test]
1432    fn service_def_with_relationships_builder() {
1433        let service = ServiceDef::new("order").relationship(
1434            RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
1435                .foreign_key("customer_id"),
1436        );
1437
1438        assert_eq!(service.relationships.len(), 1);
1439        assert_eq!(service.relationships[0].name, "customer");
1440        assert_eq!(service.relationships[0].target, "customer");
1441        assert_eq!(service.relationships[0].cardinality, Cardinality::ManyToOne);
1442    }
1443
1444    #[test]
1445    fn service_def_belongs_to_convenience() {
1446        let service = ServiceDef::new("order").belongs_to("customer", "customer");
1447
1448        assert_eq!(service.relationships.len(), 1);
1449        let rel = &service.relationships[0];
1450        assert_eq!(rel.name, "customer");
1451        assert_eq!(rel.target, "customer");
1452        assert_eq!(rel.cardinality, Cardinality::ManyToOne);
1453        assert_eq!(rel.navigation, NavigationHint::Link);
1454    }
1455
1456    #[test]
1457    fn service_def_has_many_convenience() {
1458        let service = ServiceDef::new("order").has_many("line_items", "order_line_item");
1459
1460        assert_eq!(service.relationships.len(), 1);
1461        let rel = &service.relationships[0];
1462        assert_eq!(rel.name, "line_items");
1463        assert_eq!(rel.target, "order_line_item");
1464        assert_eq!(rel.cardinality, Cardinality::OneToMany);
1465        assert_eq!(rel.navigation, NavigationHint::Nested);
1466    }
1467
1468    #[test]
1469    fn service_def_has_one_convenience() {
1470        let service = ServiceDef::new("user").has_one("profile", "user_profile");
1471
1472        assert_eq!(service.relationships.len(), 1);
1473        let rel = &service.relationships[0];
1474        assert_eq!(rel.name, "profile");
1475        assert_eq!(rel.target, "user_profile");
1476        assert_eq!(rel.cardinality, Cardinality::OneToOne);
1477        assert_eq!(rel.navigation, NavigationHint::Inline);
1478    }
1479
1480    #[test]
1481    fn service_def_belongs_to_many_convenience() {
1482        let service = ServiceDef::new("post").belongs_to_many("tags", "tag");
1483
1484        assert_eq!(service.relationships.len(), 1);
1485        let rel = &service.relationships[0];
1486        assert_eq!(rel.name, "tags");
1487        assert_eq!(rel.target, "tag");
1488        assert_eq!(rel.cardinality, Cardinality::ManyToMany);
1489        assert_eq!(rel.navigation, NavigationHint::Nested);
1490    }
1491
1492    #[test]
1493    fn service_def_json_omits_empty_relationships() {
1494        let service = ServiceDef::new("user");
1495        let json = serde_json::to_string(&service).unwrap();
1496        assert!(!json.contains("relationships"));
1497    }
1498
1499    #[test]
1500    fn service_def_relationships_serde_round_trip() {
1501        let service = ServiceDef::new("order")
1502            .field("id", DataType::Integer, FieldMeaning::Identifier)
1503            .belongs_to("customer", "customer")
1504            .has_many("line_items", "order_line_item")
1505            .has_one("invoice", "invoice")
1506            .belongs_to_many("tags", "tag");
1507
1508        let json = serde_json::to_string_pretty(&service).unwrap();
1509        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1510        assert_eq!(service, parsed);
1511        assert_eq!(parsed.relationships.len(), 4);
1512    }
1513
1514    // -- Validation tests --
1515
1516    #[test]
1517    fn validate_warns_duplicate_relationship_names() {
1518        let service = ServiceDef::new("order")
1519            .belongs_to("customer", "customer")
1520            .belongs_to("customer", "other_customer");
1521
1522        let warnings = service.validate().unwrap();
1523        assert!(warnings.contains(&Warning::DuplicateRelationship("customer".into())));
1524    }
1525
1526    #[test]
1527    fn validate_warns_many_to_many_with_foreign_key() {
1528        let service = ServiceDef::new("post").relationship(
1529            RelationshipDef::new("tags", "tag", Cardinality::ManyToMany).foreign_key("tag_id"),
1530        );
1531
1532        let warnings = service.validate().unwrap();
1533        assert!(warnings.contains(&Warning::ManyToManyWithForeignKey {
1534            relationship: "tags".into()
1535        }));
1536    }
1537
1538    #[test]
1539    fn validate_passes_with_valid_relationships() {
1540        let service = ServiceDef::new("order")
1541            .field("id", DataType::Integer, FieldMeaning::Identifier)
1542            .belongs_to("customer", "customer")
1543            .has_many("line_items", "order_line_item");
1544
1545        let warnings = service.validate().unwrap();
1546        assert!(
1547            warnings.is_empty(),
1548            "expected no warnings, got: {warnings:?}"
1549        );
1550    }
1551
1552    #[test]
1553    fn order_service_with_relationships_full_example() {
1554        let machine = StateMachine::new("order_lifecycle")
1555            .initial("draft")
1556            .state(StateDef::new("draft").display_name("Draft"))
1557            .state(
1558                StateDef::new("submitted")
1559                    .display_name("Submitted")
1560                    .final_state(),
1561            )
1562            .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
1563
1564        let service = ServiceDef::new("order")
1565            .display_name("Order")
1566            .description("Full order management with relationships")
1567            .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1568            .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1569            .field("total", DataType::Float, FieldMeaning::Money)
1570            .field("status", DataType::String, FieldMeaning::Status)
1571            .guard(GuardDef::new("has_items"))
1572            .action(
1573                ActionDef::new("submit_order")
1574                    .precondition("has_items")
1575                    .transition_trigger("submit"),
1576            )
1577            .belongs_to("customer", "customer")
1578            .has_many("line_items", "order_line_item")
1579            .has_one("invoice", "invoice")
1580            .state_machine(machine);
1581
1582        // Validate passes with no warnings
1583        let warnings = service.validate().unwrap();
1584        assert!(
1585            warnings.is_empty(),
1586            "expected no warnings, got: {warnings:?}"
1587        );
1588
1589        // All pieces present
1590        assert_eq!(service.fields.len(), 4);
1591        assert_eq!(service.guards.len(), 1);
1592        assert_eq!(service.actions.len(), 1);
1593        assert_eq!(service.relationships.len(), 3);
1594        assert!(service.state_machine.is_some());
1595
1596        // Serde round-trip
1597        let json = serde_json::to_string_pretty(&service).unwrap();
1598        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1599        assert_eq!(service, parsed);
1600    }
1601
1602    #[test]
1603    fn mcp_exposed_defaults_false_when_absent() {
1604        let json = r#"{"name":"order","fields":[]}"#;
1605        let parsed: ServiceDef = serde_json::from_str(json).unwrap();
1606        assert!(!parsed.mcp_exposed);
1607    }
1608
1609    #[test]
1610    fn mcp_exposed_builder_sets_flag() {
1611        let s = ServiceDef::new("order").mcp_exposed(true);
1612        assert!(s.mcp_exposed);
1613    }
1614
1615    #[test]
1616    fn tenant_and_ability_default_none_when_absent() {
1617        let json = r#"{"name":"order","fields":[]}"#;
1618        let parsed: ServiceDef = serde_json::from_str(json).unwrap();
1619        assert!(parsed.tenant_column.is_none());
1620        assert!(parsed.mcp_ability.is_none());
1621    }
1622
1623    #[test]
1624    fn tenant_column_and_mcp_ability_builder_sets_values() {
1625        let s = ServiceDef::new("order")
1626            .tenant_column("tenant_id")
1627            .mcp_ability("view-orders");
1628        assert_eq!(s.tenant_column, Some("tenant_id".to_string()));
1629        assert_eq!(s.mcp_ability, Some("view-orders".to_string()));
1630    }
1631
1632    #[test]
1633    fn tenant_column_and_mcp_ability_skip_serializing_when_none() {
1634        let s = ServiceDef::new("order").field(
1635            "id",
1636            crate::field::DataType::Integer,
1637            crate::field::FieldMeaning::Identifier,
1638        );
1639        let json = serde_json::to_string(&s).unwrap();
1640        assert!(
1641            !json.contains("tenant_column"),
1642            "tenant_column should be absent when None"
1643        );
1644        assert!(
1645            !json.contains("mcp_ability"),
1646            "mcp_ability should be absent when None"
1647        );
1648    }
1649
1650    #[test]
1651    fn service_def_json_schema_includes_relationships() {
1652        let schema = schemars::schema_for!(ServiceDef);
1653        let value = schema.to_value();
1654        let props = value
1655            .get("properties")
1656            .expect("ServiceDef schema must have properties");
1657        let obj = props.as_object().unwrap();
1658        assert!(
1659            obj.contains_key("relationships"),
1660            "missing 'relationships' property"
1661        );
1662    }
1663
1664    // -- Phase 88-01 tests: intent hints --
1665
1666    use crate::intent::{Intent, IntentHint};
1667
1668    #[test]
1669    fn service_def_new_has_empty_intent_hints() {
1670        let service = ServiceDef::new("order");
1671        assert!(service.intent_hints.is_empty());
1672    }
1673
1674    #[test]
1675    fn service_def_intent_hint_builder() {
1676        let service = ServiceDef::new("order")
1677            .intent_hint(IntentHint::Primary(Intent::Browse))
1678            .intent_hint(IntentHint::Exclude(Intent::Process));
1679
1680        assert_eq!(service.intent_hints.len(), 2);
1681        assert_eq!(service.intent_hints[0], IntentHint::Primary(Intent::Browse));
1682        assert_eq!(
1683            service.intent_hints[1],
1684            IntentHint::Exclude(Intent::Process)
1685        );
1686    }
1687
1688    #[test]
1689    fn service_def_json_omits_empty_intent_hints() {
1690        let service = ServiceDef::new("user");
1691        let json = serde_json::to_string(&service).unwrap();
1692        assert!(!json.contains("intent_hints"));
1693    }
1694
1695    #[test]
1696    fn service_def_intent_hints_serde_round_trip() {
1697        let service = ServiceDef::new("order")
1698            .field("id", DataType::Integer, FieldMeaning::Identifier)
1699            .intent_hint(IntentHint::Primary(Intent::Browse))
1700            .intent_hint(IntentHint::Exclude(Intent::Collect));
1701
1702        let json = serde_json::to_string_pretty(&service).unwrap();
1703        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1704        assert_eq!(service, parsed);
1705        assert_eq!(parsed.intent_hints.len(), 2);
1706    }
1707
1708    #[test]
1709    fn validate_passes_with_valid_intent_hints() {
1710        let service = ServiceDef::new("order")
1711            .field("id", DataType::Integer, FieldMeaning::Identifier)
1712            .intent_hint(IntentHint::Primary(Intent::Browse))
1713            .intent_hint(IntentHint::Exclude(Intent::Collect));
1714
1715        let warnings = service.validate().unwrap();
1716        assert!(
1717            warnings.is_empty(),
1718            "expected no warnings, got: {warnings:?}"
1719        );
1720    }
1721
1722    #[test]
1723    fn validate_warns_conflicting_intent_hints() {
1724        let service = ServiceDef::new("order")
1725            .intent_hint(IntentHint::Primary(Intent::Browse))
1726            .intent_hint(IntentHint::Exclude(Intent::Browse));
1727
1728        let warnings = service.validate().unwrap();
1729        assert!(warnings.contains(&Warning::ConflictingIntentHints {
1730            intent: "browse".into()
1731        }));
1732    }
1733
1734    #[test]
1735    fn validate_warns_multiple_primary_intent_hints() {
1736        let service = ServiceDef::new("order")
1737            .intent_hint(IntentHint::Primary(Intent::Browse))
1738            .intent_hint(IntentHint::Primary(Intent::Focus));
1739
1740        let warnings = service.validate().unwrap();
1741        assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1742    }
1743
1744    #[test]
1745    fn validate_warns_both_conflicting_and_multiple_primary() {
1746        let service = ServiceDef::new("order")
1747            .intent_hint(IntentHint::Primary(Intent::Browse))
1748            .intent_hint(IntentHint::Primary(Intent::Focus))
1749            .intent_hint(IntentHint::Exclude(Intent::Browse));
1750
1751        let warnings = service.validate().unwrap();
1752        assert!(warnings.contains(&Warning::ConflictingIntentHints {
1753            intent: "browse".into()
1754        }));
1755        assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1756    }
1757
1758    #[test]
1759    fn validate_no_warning_for_single_primary() {
1760        let service = ServiceDef::new("order").intent_hint(IntentHint::Primary(Intent::Browse));
1761
1762        let warnings = service.validate().unwrap();
1763        assert!(
1764            warnings.is_empty(),
1765            "expected no warnings, got: {warnings:?}"
1766        );
1767    }
1768
1769    #[test]
1770    fn service_def_json_schema_includes_intent_hints() {
1771        let schema = schemars::schema_for!(ServiceDef);
1772        let value = schema.to_value();
1773        let props = value
1774            .get("properties")
1775            .expect("ServiceDef schema must have properties");
1776        let obj = props.as_object().unwrap();
1777        assert!(
1778            obj.contains_key("intent_hints"),
1779            "missing 'intent_hints' property"
1780        );
1781    }
1782
1783    // -- Phase 88-02 tests: full integration with intent hints --
1784
1785    #[test]
1786    fn full_service_with_intent_hints() {
1787        let machine = StateMachine::new("order_lifecycle")
1788            .initial("draft")
1789            .state(StateDef::new("draft").display_name("Draft"))
1790            .state(
1791                StateDef::new("submitted")
1792                    .display_name("Submitted")
1793                    .on_enter(vec!["validate_inventory"]),
1794            )
1795            .state(
1796                StateDef::new("shipped")
1797                    .display_name("Shipped")
1798                    .final_state(),
1799            )
1800            .state(
1801                StateDef::new("cancelled")
1802                    .display_name("Cancelled")
1803                    .final_state(),
1804            )
1805            .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
1806            .transition(
1807                Transition::new("submitted", "ship", "shipped").guard("inventory_fulfilled"),
1808            )
1809            .transition(
1810                Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1811            )
1812            .transition(
1813                Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1814            );
1815
1816        let service = ServiceDef::new("order")
1817            .display_name("Order")
1818            .description("Full order management with all features including intent hints")
1819            .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1820            .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1821            .field("total", DataType::Float, FieldMeaning::Money)
1822            .field("status", DataType::String, FieldMeaning::Status)
1823            .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1824            .guard(GuardDef::new("has_items"))
1825            .guard(GuardDef::new("inventory_fulfilled"))
1826            .guard(GuardDef::new("cancellation_allowed"))
1827            .action(
1828                ActionDef::new("submit_order")
1829                    .precondition("has_items")
1830                    .transition_trigger("submit"),
1831            )
1832            .action(
1833                ActionDef::new("ship_order")
1834                    .precondition("inventory_fulfilled")
1835                    .transition_trigger("ship"),
1836            )
1837            .action(
1838                ActionDef::new("cancel_order")
1839                    .precondition("cancellation_allowed")
1840                    .transition_trigger("cancel"),
1841            )
1842            .belongs_to("customer", "customer")
1843            .has_many("line_items", "order_line_item")
1844            .has_one("invoice", "invoice")
1845            .intent_hint(IntentHint::Primary(Intent::Process))
1846            .intent_hint(IntentHint::Exclude(Intent::Summarize))
1847            .state_machine(machine);
1848
1849        // Validate passes with no warnings
1850        let warnings = service.validate().unwrap();
1851        assert!(
1852            warnings.is_empty(),
1853            "expected no warnings, got: {warnings:?}"
1854        );
1855
1856        // All pieces present
1857        assert_eq!(service.fields.len(), 5);
1858        assert_eq!(service.guards.len(), 3);
1859        assert_eq!(service.actions.len(), 3);
1860        assert_eq!(service.relationships.len(), 3);
1861        assert_eq!(service.intent_hints.len(), 2);
1862        assert!(service.state_machine.is_some());
1863
1864        // Intent hints correct
1865        assert_eq!(
1866            service.intent_hints[0],
1867            IntentHint::Primary(Intent::Process)
1868        );
1869        assert_eq!(
1870            service.intent_hints[1],
1871            IntentHint::Exclude(Intent::Summarize)
1872        );
1873
1874        // Serde round-trip
1875        let json = serde_json::to_string_pretty(&service).unwrap();
1876        let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1877        assert_eq!(service, parsed);
1878    }
1879
1880    fn order_meta() -> ModelMetadata {
1881        ModelMetadata {
1882            name: "order".to_string(),
1883            display_name: None,
1884            table: Some("orders".to_string()),
1885            fields: vec![
1886                FieldMetadata {
1887                    name: "id".into(),
1888                    column_type: "i32".into(),
1889                    is_primary_key: true,
1890                    is_nullable: false,
1891                },
1892                FieldMetadata {
1893                    name: "total".into(),
1894                    column_type: "f64".into(),
1895                    is_primary_key: false,
1896                    is_nullable: false,
1897                },
1898                FieldMetadata {
1899                    name: "status".into(),
1900                    column_type: "String".into(),
1901                    is_primary_key: false,
1902                    is_nullable: false,
1903                },
1904                FieldMetadata {
1905                    name: "notes".into(),
1906                    column_type: "Option<String>".into(),
1907                    is_primary_key: false,
1908                    is_nullable: true,
1909                },
1910                FieldMetadata {
1911                    name: "created_at".into(),
1912                    column_type: "DateTime<Utc>".into(),
1913                    is_primary_key: false,
1914                    is_nullable: false,
1915                },
1916            ],
1917        }
1918    }
1919
1920    #[test]
1921    fn from_model_basic() {
1922        let meta = order_meta();
1923        let def = ServiceDef::from_model(&meta);
1924        assert_eq!(def.name, "order");
1925        assert_eq!(def.display_name.as_deref(), Some("Order"));
1926        assert_eq!(def.fields.len(), 5);
1927    }
1928
1929    #[test]
1930    fn from_model_system_fields_read_only() {
1931        let meta = order_meta();
1932        let def = ServiceDef::from_model(&meta);
1933        let id = def.fields.iter().find(|f| f.name == "id").unwrap();
1934        assert!(!id.writable, "id must be read-only");
1935        let created_at = def.fields.iter().find(|f| f.name == "created_at").unwrap();
1936        assert!(!created_at.writable, "created_at must be read-only");
1937        let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1938        assert!(total.writable, "total must be writable");
1939    }
1940
1941    #[test]
1942    fn from_model_nullable_to_required() {
1943        let meta = order_meta();
1944        let def = ServiceDef::from_model(&meta);
1945        let notes = def.fields.iter().find(|f| f.name == "notes").unwrap();
1946        assert!(!notes.required, "nullable field must have required: false");
1947        let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1948        assert!(
1949            total.required,
1950            "non-nullable field must have required: true"
1951        );
1952    }
1953
1954    #[test]
1955    fn from_model_display_name_override() {
1956        let meta = ModelMetadata {
1957            name: "order".to_string(),
1958            display_name: Some("Custom Name".to_string()),
1959            table: None,
1960            fields: vec![],
1961        };
1962        let def = ServiceDef::from_model(&meta);
1963        assert_eq!(def.display_name.as_deref(), Some("Custom Name"));
1964    }
1965
1966    #[test]
1967    fn from_model_snake_to_title() {
1968        let meta = ModelMetadata {
1969            name: "order_item".to_string(),
1970            display_name: None,
1971            table: None,
1972            fields: vec![],
1973        };
1974        let def = ServiceDef::from_model(&meta);
1975        assert_eq!(def.display_name.as_deref(), Some("Order Item"));
1976    }
1977
1978    #[test]
1979    fn round_trip_model_to_intents() {
1980        use crate::derive::derive_intents;
1981
1982        let meta = order_meta();
1983        let def = ServiceDef::from_model(&meta);
1984        let intents = derive_intents(&def);
1985        assert!(
1986            !intents.is_empty(),
1987            "derive_intents must produce at least one intent score"
1988        );
1989    }
1990
1991    // ── Track A: CRUD data-surface declaration ──────────────────────────────
1992
1993    #[test]
1994    fn crud_flags_default_false() {
1995        let def = ServiceDef::new("order");
1996        assert!(!def.creatable);
1997        assert!(!def.updatable);
1998        assert!(!def.deletable);
1999        assert!(def.mcp_write_ability.is_none());
2000        assert!(def.table.is_none());
2001        assert!(def.soft_delete_column.is_none());
2002    }
2003
2004    #[test]
2005    fn crud_builders_set_flags() {
2006        let def = ServiceDef::new("order")
2007            .creatable(true)
2008            .updatable(true)
2009            .deletable(true);
2010        assert!(def.creatable);
2011        assert!(def.updatable);
2012        assert!(def.deletable);
2013    }
2014
2015    #[test]
2016    fn write_surface_builders_set_fields() {
2017        let def = ServiceDef::new("order")
2018            .mcp_write_ability("manage-orders")
2019            .table("orders")
2020            .soft_delete_column("deleted_at");
2021        assert_eq!(def.mcp_write_ability.as_deref(), Some("manage-orders"));
2022        assert_eq!(def.table.as_deref(), Some("orders"));
2023        assert_eq!(def.soft_delete_column.as_deref(), Some("deleted_at"));
2024    }
2025
2026    #[test]
2027    fn validate_rejects_creatable_without_write_ability() {
2028        let def = ServiceDef::new("order").creatable(true);
2029        let err = def.validate().unwrap_err();
2030        assert!(matches!(err, crate::Error::Validation(_)));
2031        assert!(
2032            err.to_string().contains("mcp_write_ability"),
2033            "error message must mention 'mcp_write_ability'; got: {err}"
2034        );
2035    }
2036
2037    #[test]
2038    fn validate_rejects_updatable_without_write_ability() {
2039        let def = ServiceDef::new("order").updatable(true);
2040        let err = def.validate().unwrap_err();
2041        assert!(matches!(err, crate::Error::Validation(_)));
2042        assert!(
2043            err.to_string().contains("mcp_write_ability"),
2044            "error message must mention 'mcp_write_ability'; got: {err}"
2045        );
2046    }
2047
2048    #[test]
2049    fn validate_rejects_deletable_without_write_ability() {
2050        let def = ServiceDef::new("order").deletable(true);
2051        let err = def.validate().unwrap_err();
2052        assert!(matches!(err, crate::Error::Validation(_)));
2053        assert!(
2054            err.to_string().contains("mcp_write_ability"),
2055            "error message must mention 'mcp_write_ability'; got: {err}"
2056        );
2057    }
2058
2059    #[test]
2060    fn validate_accepts_crud_with_write_ability() {
2061        let def = ServiceDef::new("order")
2062            .creatable(true)
2063            .updatable(true)
2064            .deletable(true)
2065            .mcp_write_ability("manage-orders");
2066        assert!(def.validate().is_ok());
2067    }
2068
2069    #[test]
2070    fn validate_allows_read_only_without_write_ability() {
2071        // A projection that enables no CRUD verb must not require a write ability.
2072        let def = ServiceDef::new("order");
2073        assert!(def.validate().is_ok());
2074    }
2075
2076    #[test]
2077    fn serde_round_trip_includes_crud_surface() {
2078        let def = ServiceDef::new("order")
2079            .creatable(true)
2080            .updatable(true)
2081            .deletable(true)
2082            .mcp_write_ability("manage-orders")
2083            .table("orders")
2084            .soft_delete_column("deleted_at");
2085        let json = serde_json::to_string(&def).unwrap();
2086        let back: ServiceDef = serde_json::from_str(&json).unwrap();
2087        assert!(back.creatable && back.updatable && back.deletable);
2088        assert_eq!(back.mcp_write_ability.as_deref(), Some("manage-orders"));
2089        assert_eq!(back.table.as_deref(), Some("orders"));
2090        assert_eq!(back.soft_delete_column.as_deref(), Some("deleted_at"));
2091    }
2092
2093    // ── Phase 239: resolver accessors ───────────────────────────────────────
2094
2095    #[test]
2096    fn resolved_table_default() {
2097        assert_eq!(ServiceDef::new("order").resolved_table(), "orders");
2098    }
2099
2100    #[test]
2101    fn resolved_table_default_lowercases() {
2102        assert_eq!(ServiceDef::new("Order").resolved_table(), "orders");
2103    }
2104
2105    #[test]
2106    fn resolved_table_explicit_override() {
2107        assert_eq!(
2108            ServiceDef::new("order")
2109                .table("purchase_orders")
2110                .resolved_table(),
2111            "purchase_orders"
2112        );
2113    }
2114
2115    #[test]
2116    fn resolved_soft_delete_column_default() {
2117        assert_eq!(
2118            ServiceDef::new("order").resolved_soft_delete_column(),
2119            "deleted_at"
2120        );
2121    }
2122
2123    #[test]
2124    fn resolved_soft_delete_column_explicit_override() {
2125        assert_eq!(
2126            ServiceDef::new("order")
2127                .soft_delete_column("removed_at")
2128                .resolved_soft_delete_column(),
2129            "removed_at"
2130        );
2131    }
2132
2133    // ── Phase 239: is_server_injected_field ─────────────────────────────────
2134
2135    fn mk_field(name: &str, meaning: FieldMeaning) -> FieldDef {
2136        FieldDef {
2137            name: name.to_string(),
2138            data_type: DataType::String,
2139            meaning,
2140            required: false,
2141            is_list: false,
2142            readable: true,
2143            writable: true,
2144            render_hint: None,
2145        }
2146    }
2147
2148    #[test]
2149    fn server_injected_identifier() {
2150        assert!(ServiceDef::new("order")
2151            .is_server_injected_field(&mk_field("id", FieldMeaning::Identifier)));
2152    }
2153
2154    #[test]
2155    fn server_injected_created_at() {
2156        assert!(ServiceDef::new("order")
2157            .is_server_injected_field(&mk_field("created_at", FieldMeaning::CreatedAt)));
2158    }
2159
2160    #[test]
2161    fn server_injected_tenant_column() {
2162        let svc = ServiceDef::new("order").tenant_column("tenant_id");
2163        assert!(svc.is_server_injected_field(&mk_field("tenant_id", FieldMeaning::ForeignKey)));
2164    }
2165
2166    #[test]
2167    fn server_injected_false_for_regular_field() {
2168        let svc = ServiceDef::new("order").tenant_column("tenant_id");
2169        assert!(!svc.is_server_injected_field(&mk_field("customer_name", FieldMeaning::EntityName)));
2170    }
2171
2172    // ── Phase 240: is_write_excluded_field ──────────────────────────────────
2173
2174    fn mk_field_typed(name: &str, dt: DataType, meaning: FieldMeaning, is_list: bool) -> FieldDef {
2175        FieldDef {
2176            name: name.to_string(),
2177            data_type: dt,
2178            meaning,
2179            required: true,
2180            is_list,
2181            readable: true,
2182            writable: true,
2183            render_hint: None,
2184        }
2185    }
2186
2187    #[test]
2188    fn is_write_excluded_field_gates() {
2189        use crate::state::{StateDef as SD, StateMachine as SM, Transition as TR};
2190
2191        let minimal_sm = SM::new("lifecycle")
2192            .initial("a")
2193            .state(SD::new("a"))
2194            .state(SD::new("b").final_state())
2195            .transition(TR::new("a", "go", "b"));
2196
2197        // Cases: (field_name, data_type, meaning, is_list, sm_present, expected_excluded)
2198        struct Case {
2199            name: &'static str,
2200            dt: DataType,
2201            meaning: FieldMeaning,
2202            is_list: bool,
2203            sm_present: bool,
2204            expected: bool,
2205        }
2206
2207        let cases = [
2208            // Gate A — Identifier (server-injected)
2209            Case {
2210                name: "id",
2211                dt: DataType::Integer,
2212                meaning: FieldMeaning::Identifier,
2213                is_list: false,
2214                sm_present: false,
2215                expected: true,
2216            },
2217            // Gate A — CreatedAt (server-injected)
2218            Case {
2219                name: "created_at",
2220                dt: DataType::DateTime,
2221                meaning: FieldMeaning::CreatedAt,
2222                is_list: false,
2223                sm_present: false,
2224                expected: true,
2225            },
2226            // Gate A — tenant column (server-injected); tested via tenant_column() below
2227            // Gate B — UpdatedAt
2228            Case {
2229                name: "updated_at",
2230                dt: DataType::DateTime,
2231                meaning: FieldMeaning::UpdatedAt,
2232                is_list: false,
2233                sm_present: false,
2234                expected: true,
2235            },
2236            // Gate C — Sensitive
2237            Case {
2238                name: "password",
2239                dt: DataType::String,
2240                meaning: FieldMeaning::Sensitive,
2241                is_list: false,
2242                sm_present: false,
2243                expected: true,
2244            },
2245            // Gate D — list field (any meaning)
2246            Case {
2247                name: "tags",
2248                dt: DataType::String,
2249                meaning: FieldMeaning::Category,
2250                is_list: true,
2251                sm_present: false,
2252                expected: true,
2253            },
2254            // Gate E — Status excluded when SM present
2255            Case {
2256                name: "status",
2257                dt: DataType::String,
2258                meaning: FieldMeaning::Status,
2259                is_list: false,
2260                sm_present: true,
2261                expected: true,
2262            },
2263            // Gate E — Status NOT excluded when no SM
2264            Case {
2265                name: "status",
2266                dt: DataType::String,
2267                meaning: FieldMeaning::Status,
2268                is_list: false,
2269                sm_present: false,
2270                expected: false,
2271            },
2272            // Ordinary writable field — never excluded (both SM flags)
2273            Case {
2274                name: "notes",
2275                dt: DataType::String,
2276                meaning: FieldMeaning::FreeText,
2277                is_list: false,
2278                sm_present: false,
2279                expected: false,
2280            },
2281            Case {
2282                name: "notes",
2283                dt: DataType::String,
2284                meaning: FieldMeaning::FreeText,
2285                is_list: false,
2286                sm_present: true,
2287                expected: false,
2288            },
2289        ];
2290
2291        for c in &cases {
2292            let field = mk_field_typed(c.name, c.dt, c.meaning.clone(), c.is_list);
2293            let svc = if c.sm_present {
2294                ServiceDef::new("order").state_machine(minimal_sm.clone())
2295            } else {
2296                ServiceDef::new("order")
2297            };
2298            let got = svc.is_write_excluded_field(&field, c.sm_present);
2299            assert_eq!(
2300                got, c.expected,
2301                "field '{}' (meaning={:?}, is_list={}, sm_present={}): expected excluded={}, got={}",
2302                c.name, c.meaning, c.is_list, c.sm_present, c.expected, got
2303            );
2304        }
2305
2306        // Gate A — tenant column case (requires tenant_column set on service)
2307        let tenant_field =
2308            mk_field_typed("org_id", DataType::Integer, FieldMeaning::ForeignKey, false);
2309        let svc_with_tenant = ServiceDef::new("order").tenant_column("org_id");
2310        assert!(
2311            svc_with_tenant.is_write_excluded_field(&tenant_field, false),
2312            "tenant column must be write-excluded regardless of SM flag"
2313        );
2314    }
2315
2316    /// Gate F: a field with `writable: false` must be excluded from write input schemas.
2317    ///
2318    /// Added for Phase 243.1 — derived/computed fields (like `order.total`) are
2319    /// declared via `.read_only_field()` and must never appear in create_/update_ inputs.
2320    #[test]
2321    fn is_write_excluded_field_excludes_read_only() {
2322        let svc = ServiceDef::new("order")
2323            .field("customer_name", DataType::String, FieldMeaning::EntityName)
2324            .read_only_field("total", DataType::Float, FieldMeaning::Money);
2325
2326        let writable = &svc.fields[0];
2327        let read_only = &svc.fields[1];
2328
2329        // Writable data field is NOT excluded.
2330        assert!(!svc.is_write_excluded_field(writable, false));
2331        // Read-only (writable: false) field IS excluded from write input.
2332        assert!(svc.is_write_excluded_field(read_only, false));
2333    }
2334
2335    /// CRUD-07 boot-time test: validate() must reject any projection that enables a write
2336    /// verb without declaring mcp_write_ability. The rule ships at service.rs:502-510
2337    /// (commit 5cb17d60). This test pins the fail-fast guarantee against regression.
2338    #[test]
2339    fn validate_rejects_crud_verb_without_write_ability() {
2340        // creatable with no mcp_write_ability → Err
2341        let svc = ServiceDef::new("order")
2342            .mcp_exposed(true)
2343            .creatable(true)
2344            .field("id", DataType::Integer, FieldMeaning::Identifier);
2345        let err = svc.validate().unwrap_err();
2346        assert!(matches!(err, crate::Error::Validation(_)));
2347        assert!(err.to_string().contains("mcp_write_ability"));
2348
2349        // updatable with no mcp_write_ability → Err
2350        let svc_u = ServiceDef::new("order")
2351            .mcp_exposed(true)
2352            .updatable(true)
2353            .field("id", DataType::Integer, FieldMeaning::Identifier);
2354        assert!(svc_u.validate().is_err());
2355
2356        // deletable with no mcp_write_ability → Err
2357        let svc_d = ServiceDef::new("order")
2358            .mcp_exposed(true)
2359            .deletable(true)
2360            .field("id", DataType::Integer, FieldMeaning::Identifier);
2361        assert!(svc_d.validate().is_err());
2362
2363        // With mcp_write_ability → Ok
2364        let svc_ok = ServiceDef::new("order")
2365            .mcp_exposed(true)
2366            .creatable(true)
2367            .mcp_write_ability("manage-orders")
2368            .field("id", DataType::Integer, FieldMeaning::Identifier);
2369        assert!(svc_ok.validate().is_ok());
2370    }
2371}