Skip to main content

ferro_projections/
executor.rs

1//! Pure, serializable derivation of write plans.
2//!
3//! `derive_transition_plan` reads a declared [`StateMachine`](crate::StateMachine)
4//! plus an [`ActionDef`](crate::ActionDef) and produces a [`TransitionPlan`] — the
5//! transition facts (`from_states` → `event` → `to_state`, guard, effects) the
6//! consumer runtime needs to execute a write.
7//!
8//! `derive_crud_plan` reads a [`ServiceDef`](crate::ServiceDef) and a [`CrudVerb`]
9//! and produces a [`CrudPlan`] — the CRUD write facts (table, column set, soft-delete
10//! predicate) the `framework::write` kernel needs to execute a generic INSERT/UPDATE/
11//! soft-delete. Both plans are **data, not behavior**: this crate touches no database
12//! and runs no I/O.
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use crate::field::FieldMeaning;
18use crate::state::Transition;
19
20/// A serializable description of a state-transition write, derived from the
21/// declared `StateMachine` + `ActionDef`.
22///
23/// Carries no behavior — the consumer interprets it against a concrete entity.
24/// `to_state` is sourced only from `Transition.to`, eliminating the duplicated
25/// hand-written transition target on the write path.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub struct TransitionPlan {
28    /// `ActionDef.name` that produced this plan.
29    pub action: String,
30    /// = `ActionDef.transition_trigger`; the `StateMachine` event name.
31    pub event: String,
32    /// Every `Transition.from` carrying this event (multi-source aware).
33    pub from_states: Vec<String>,
34    /// `Transition.to` — the single target. Replaces the hand-written `match`.
35    pub to_state: String,
36    /// `Transition.guard`, if any — re-checked LIVE at execution (EXEC-02).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub guard: Option<String>,
39    /// `Transition.actions` ∪ `ActionDef.effects` — override-hook inputs.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub effects: Vec<String>,
42}
43
44/// Derives the [`TransitionPlan`] for `action_name` from the service's declared
45/// state machine.
46///
47/// Pure: no database access, no async, no closures. Returns a typed `Err` for
48/// every failure mode rather than panicking.
49///
50/// # Errors
51/// - [`Error::Validation`](crate::Error::Validation) — no action named `action_name`.
52/// - [`Error::NoTransitionTrigger`](crate::Error::NoTransitionTrigger) — the action
53///   declares no `transition_trigger`.
54/// - [`Error::NoStateMachine`](crate::Error::NoStateMachine) — the service has no
55///   state machine.
56/// - [`Error::UndeclaredTransition`](crate::Error::UndeclaredTransition) — the trigger
57///   names an event no transition carries (the EXEC-04 drift condition).
58/// - [`Error::AmbiguousTransition`](crate::Error::AmbiguousTransition) — the event fans
59///   out to more than one target state.
60pub fn derive_transition_plan(
61    svc: &crate::ServiceDef,
62    action_name: &str,
63) -> Result<TransitionPlan, crate::Error> {
64    let action = svc
65        .actions
66        .iter()
67        .find(|a| a.name == action_name)
68        .ok_or_else(|| crate::Error::Validation(format!("no action '{action_name}'")))?;
69
70    let event = action
71        .transition_trigger
72        .as_deref()
73        .ok_or_else(|| crate::Error::NoTransitionTrigger(action_name.to_string()))?;
74
75    let sm = svc
76        .state_machine
77        .as_ref()
78        .ok_or_else(|| crate::Error::NoStateMachine(svc.name.clone()))?;
79
80    let matches: Vec<&Transition> = sm.states_for_event(event);
81    if matches.is_empty() {
82        return Err(crate::Error::UndeclaredTransition {
83            action: action_name.to_string(),
84            event: event.to_string(),
85        });
86    }
87
88    let to_state = matches[0].to.clone();
89    if matches.iter().any(|t| t.to != to_state) {
90        return Err(crate::Error::AmbiguousTransition {
91            event: event.to_string(),
92        });
93    }
94
95    let from_states: Vec<String> = matches.iter().map(|t| t.from.clone()).collect();
96
97    // Guard is per-transition; all matches share one event/target. Take the
98    // first transition's guard. Dedup against action.preconditions happens in
99    // the consumer runtime (Plan 02), not here.
100    let guard = matches[0].guard.clone();
101
102    // effects: union of the transition's actions and the action's effects,
103    // order-preserving, deduped by string equality.
104    let mut effects: Vec<String> = Vec::new();
105    for e in matches[0].actions.iter().chain(action.effects.iter()) {
106        if !effects.iter().any(|existing| existing == e) {
107            effects.push(e.clone());
108        }
109    }
110
111    Ok(TransitionPlan {
112        action: action_name.to_string(),
113        event: event.to_string(),
114        from_states,
115        to_state,
116        guard,
117        effects,
118    })
119}
120
121/// The three generic CRUD write verbs derived from a projection's opt-in flags.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123#[serde(rename_all = "snake_case")]
124pub enum CrudVerb {
125    Create,
126    Update,
127    Delete,
128}
129
130/// Tenant-scope predicate slot. Phase 241 leaves this `None` on every plan;
131/// Phase 242 fills it (column name only — the runtime `tenant_id` is injected by
132/// the executor from the `dispatch_write` `tenant_id` parameter, never stored here).
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct TenantColumn {
135    pub column: String,
136}
137
138/// A pure, serializable CRUD write plan — the data-only analog of [`TransitionPlan`].
139///
140/// Carries no behavior: `framework::write::execute_crud_plan` interprets it into
141/// parameterized SQL. Values are [`serde_json::Value`] because this crate has no
142/// sea-orm dependency (the schema-only boundary); the framework executor coerces
143/// `serde_json::Value` to the SQL binding type at execution time.
144///
145/// # created_at contract
146///
147/// `created_at` is **not** included in `CrudPlan::Create.columns`. The framework
148/// executor injects it as a server-side `datetime('now')` / `NOW()` literal.
149/// This keeps the plan free of magic sentinels and matches the DB DEFAULT behavior.
150///
151/// # tenant_column contract (D-09)
152///
153/// Every variant carries `tenant_column: Option<TenantColumn>`, always `None` in
154/// Phase 241. Phase 242 sets `Some(TenantColumn { column })` so the executor can
155/// inject the runtime `tenant_id` without reworking the plan struct.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
157pub enum CrudPlan {
158    /// Generic INSERT: user-supplied writable fields plus server-set initial
159    /// Status (when a StateMachine exists). `created_at` is server-injected by
160    /// the executor (not present in `columns`).
161    Create {
162        table: String,
163        /// Ordered (column, value) pairs: user-supplied writable fields first,
164        /// then server-set Status = initial state (if StateMachine declared).
165        columns: Vec<(String, serde_json::Value)>,
166        /// None in Phase 241; Phase 242 sets `Some(TenantColumn { .. })`.
167        #[serde(skip_serializing_if = "Option::is_none")]
168        tenant_column: Option<TenantColumn>,
169    },
170    /// Generic UPDATE (patch semantics): only supplied writable fields change.
171    /// The executor always emits `AND <soft_delete_column> IS NULL` (CRUD-03 / SC#2).
172    Update {
173        table: String,
174        id_column: String,
175        id_value: serde_json::Value,
176        /// Only the supplied writable fields (patch semantics).
177        patch: Vec<(String, serde_json::Value)>,
178        /// Always present — executor emits `AND <col> IS NULL` so a soft-deleted
179        /// row is unaddressable (CRUD-03 / SC#2).
180        soft_delete_column: String,
181        /// None in Phase 241; Phase 242 sets `Some(TenantColumn { .. })`.
182        #[serde(skip_serializing_if = "Option::is_none")]
183        tenant_column: Option<TenantColumn>,
184    },
185    /// Generic soft-DELETE: sets `soft_delete_column = now` (never a physical DELETE).
186    Delete {
187        table: String,
188        id_column: String,
189        id_value: serde_json::Value,
190        /// Set to `datetime('now')` / `NOW()` by the executor (soft-delete).
191        soft_delete_column: String,
192        /// None in Phase 241; Phase 242 sets `Some(TenantColumn { .. })`.
193        #[serde(skip_serializing_if = "Option::is_none")]
194        tenant_column: Option<TenantColumn>,
195    },
196}
197
198/// Derives a pure [`CrudPlan`] for `verb` from the service declaration and agent inputs.
199///
200/// Mirrors `derive_transition_plan`: pure, side-effect-free, no I/O. Reuses the
201/// Phase 239/240 resolver accessors so the column sets are single-sourced with the
202/// schema builders.
203///
204/// Phase 241 leaves `tenant_column = None` on every variant (D-09); Phase 242 fills it.
205///
206/// # Errors
207/// - [`Error::VerbNotEnabled`](crate::Error::VerbNotEnabled) — the service has not
208///   opted into the requested verb (`.creatable(false)`, etc.).
209pub fn derive_crud_plan(
210    svc: &crate::ServiceDef,
211    verb: CrudVerb,
212    inputs: &serde_json::Value,
213) -> Result<CrudPlan, crate::Error> {
214    let table = svc.resolved_table();
215    let has_sm = svc.state_machine.is_some();
216
217    match verb {
218        CrudVerb::Create => {
219            if !svc.creatable {
220                return Err(crate::Error::VerbNotEnabled(format!("{}.create", svc.name)));
221            }
222            // Collect user-supplied writable fields (excludes Identifier, CreatedAt,
223            // UpdatedAt, Sensitive, list fields, and Status when SM exists).
224            let mut columns: Vec<(String, serde_json::Value)> = svc
225                .fields
226                .iter()
227                .filter(|f| !svc.is_write_excluded_field(f, has_sm))
228                .filter_map(|f| inputs.get(&f.name).map(|v| (f.name.clone(), v.clone())))
229                .collect();
230            // Server-set: Status = initial state when a StateMachine is declared.
231            if let Some(sm) = svc.state_machine.as_ref() {
232                if let Some(status_field) = svc
233                    .fields
234                    .iter()
235                    .find(|f| matches!(f.meaning, FieldMeaning::Status))
236                {
237                    columns.push((
238                        status_field.name.clone(),
239                        serde_json::json!(sm.initial_state),
240                    ));
241                }
242            }
243            // Note: created_at is NOT added here — the executor injects it server-side.
244            Ok(CrudPlan::Create {
245                table,
246                columns,
247                tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
248                    column: col.clone(),
249                }),
250            })
251        }
252        CrudVerb::Update => {
253            if !svc.updatable {
254                return Err(crate::Error::VerbNotEnabled(format!("{}.update", svc.name)));
255            }
256            let id_value = inputs
257                .get("id")
258                .filter(|v| !v.is_null())
259                .cloned()
260                .ok_or_else(|| crate::Error::Validation("update requires an 'id' field".into()))?;
261            // Patch: only the supplied writable fields (Status excluded when SM exists).
262            let patch = svc
263                .fields
264                .iter()
265                .filter(|f| !svc.is_write_excluded_field(f, has_sm))
266                .filter_map(|f| inputs.get(&f.name).map(|v| (f.name.clone(), v.clone())))
267                .collect();
268            Ok(CrudPlan::Update {
269                table,
270                id_column: "id".into(),
271                id_value,
272                patch,
273                soft_delete_column: svc.resolved_soft_delete_column().to_string(),
274                tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
275                    column: col.clone(),
276                }),
277            })
278        }
279        CrudVerb::Delete => {
280            if !svc.deletable {
281                return Err(crate::Error::VerbNotEnabled(format!("{}.delete", svc.name)));
282            }
283            let id_value = inputs
284                .get("id")
285                .filter(|v| !v.is_null())
286                .cloned()
287                .ok_or_else(|| crate::Error::Validation("delete requires an 'id' field".into()))?;
288            Ok(CrudPlan::Delete {
289                table,
290                id_column: "id".into(),
291                id_value,
292                soft_delete_column: svc.resolved_soft_delete_column().to_string(),
293                tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
294                    column: col.clone(),
295                }),
296            })
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::action::ActionDef;
305    use crate::state::{StateMachine, Transition};
306    use crate::ServiceDef;
307
308    /// Synthetic order service mirroring the app's EXEC-01 reference fixture.
309    /// `cancel` is multi-source (draft + submitted → cancelled) to exercise
310    /// the multi-source path. Reproduced inline — no dependency on the `app` crate.
311    fn order_service() -> ServiceDef {
312        let machine = StateMachine::new("order_lifecycle")
313            .initial("draft")
314            .transition(Transition::new("draft", "submit", "submitted").actions(vec!["log_submit"]))
315            .transition(Transition::new("submitted", "approve", "approved").guard("is_manager"))
316            .transition(Transition::new("submitted", "reject", "cancelled"))
317            .transition(Transition::new("approved", "ship", "shipped"))
318            .transition(Transition::new("shipped", "deliver", "delivered"))
319            .transition(Transition::new("draft", "cancel", "cancelled"))
320            .transition(Transition::new("submitted", "cancel", "cancelled"));
321
322        ServiceDef::new("order")
323            .state_machine(machine)
324            .action(
325                ActionDef::new("submit")
326                    .transition_trigger("submit")
327                    .effect("notify"),
328            )
329            .action(
330                ActionDef::new("approve")
331                    .transition_trigger("approve")
332                    .precondition("is_manager"),
333            )
334            .action(ActionDef::new("ship").transition_trigger("ship"))
335            .action(ActionDef::new("cancel").transition_trigger("cancel"))
336    }
337
338    #[test]
339    fn derive_transition_plan() {
340        let svc = order_service();
341        let plan = super::derive_transition_plan(&svc, "submit").unwrap();
342        assert_eq!(plan.action, "submit");
343        assert_eq!(plan.event, "submit");
344        assert_eq!(plan.to_state, "submitted");
345        assert_eq!(plan.from_states, vec!["draft".to_string()]);
346        assert_eq!(plan.guard, None);
347        // transition.actions ∪ action.effects, order-preserving, deduped.
348        assert_eq!(
349            plan.effects,
350            vec!["log_submit".to_string(), "notify".to_string()]
351        );
352    }
353
354    #[test]
355    fn derive_approve_carries_transition_guard() {
356        let svc = order_service();
357        let plan = super::derive_transition_plan(&svc, "approve").unwrap();
358        assert_eq!(plan.to_state, "approved");
359        assert_eq!(plan.guard, Some("is_manager".to_string()));
360    }
361
362    #[test]
363    fn derive_multi_source_event() {
364        let svc = order_service();
365        let plan = super::derive_transition_plan(&svc, "cancel").unwrap();
366        assert_eq!(plan.to_state, "cancelled");
367        assert!(plan.from_states.contains(&"draft".to_string()));
368        assert!(plan.from_states.contains(&"submitted".to_string()));
369        assert_eq!(plan.from_states.len(), 2);
370    }
371
372    #[test]
373    fn derive_no_trigger() {
374        let svc = ServiceDef::new("order")
375            .state_machine(StateMachine::new("sm").initial("draft"))
376            .action(ActionDef::new("noop"));
377        let err = super::derive_transition_plan(&svc, "noop").unwrap_err();
378        assert!(matches!(err, crate::Error::NoTransitionTrigger(a) if a == "noop"));
379    }
380
381    #[test]
382    fn derive_undeclared_trigger_errors() {
383        let svc = ServiceDef::new("order")
384            .state_machine(
385                StateMachine::new("sm")
386                    .initial("draft")
387                    .transition(Transition::new("draft", "submit", "submitted")),
388            )
389            .action(ActionDef::new("bogus").transition_trigger("does_not_exist"));
390        let err = super::derive_transition_plan(&svc, "bogus").unwrap_err();
391        assert!(matches!(
392            err,
393            crate::Error::UndeclaredTransition { ref action, ref event }
394                if action == "bogus" && event == "does_not_exist"
395        ));
396    }
397
398    #[test]
399    fn derive_no_state_machine() {
400        let svc =
401            ServiceDef::new("order").action(ActionDef::new("submit").transition_trigger("submit"));
402        let err = super::derive_transition_plan(&svc, "submit").unwrap_err();
403        assert!(matches!(err, crate::Error::NoStateMachine(name) if name == "order"));
404    }
405
406    #[test]
407    fn derive_no_action() {
408        let svc = order_service();
409        let err = super::derive_transition_plan(&svc, "ghost").unwrap_err();
410        assert!(matches!(err, crate::Error::Validation(_)));
411    }
412
413    #[test]
414    fn derive_ambiguous_fan_out() {
415        // Same event `split` fans out to two different targets per source.
416        let svc = ServiceDef::new("order")
417            .state_machine(
418                StateMachine::new("sm")
419                    .initial("a")
420                    .transition(Transition::new("a", "split", "b"))
421                    .transition(Transition::new("c", "split", "d")),
422            )
423            .action(ActionDef::new("split").transition_trigger("split"));
424        let err = super::derive_transition_plan(&svc, "split").unwrap_err();
425        assert!(matches!(err, crate::Error::AmbiguousTransition { event } if event == "split"));
426    }
427
428    #[test]
429    fn transition_plan_serde_round_trip() {
430        let svc = order_service();
431        let plan = super::derive_transition_plan(&svc, "submit").unwrap();
432        let json = serde_json::to_string(&plan).unwrap();
433        let back: TransitionPlan = serde_json::from_str(&json).unwrap();
434        assert_eq!(plan, back);
435    }
436
437    // ── CRUD plan derivation tests ──────────────────────────────────────────
438
439    use crate::field::{DataType, FieldMeaning};
440
441    /// CRUD-capable order service with an SM (status excluded from create/update)
442    /// and two writable data fields: `amount` (Money) and `note` (FreeText).
443    fn crud_order_service() -> ServiceDef {
444        let machine = StateMachine::new("order_lifecycle")
445            .initial("draft")
446            .transition(Transition::new("draft", "submit", "submitted"));
447
448        ServiceDef::new("order")
449            .mcp_write_ability("manage-orders")
450            .creatable(true)
451            .updatable(true)
452            .deletable(true)
453            .state_machine(machine)
454            .field("id", DataType::Integer, FieldMeaning::Identifier)
455            .field("status", DataType::String, FieldMeaning::Status)
456            .field("amount", DataType::String, FieldMeaning::Money)
457            .field("note", DataType::String, FieldMeaning::FreeText)
458            .field("created_at", DataType::String, FieldMeaning::CreatedAt)
459    }
460
461    /// Like crud_order_service but without a StateMachine (status is writable).
462    fn crud_order_service_no_sm() -> ServiceDef {
463        ServiceDef::new("order")
464            .mcp_write_ability("manage-orders")
465            .creatable(true)
466            .updatable(true)
467            .deletable(true)
468            .field("id", DataType::Integer, FieldMeaning::Identifier)
469            .field("status", DataType::String, FieldMeaning::Status)
470            .field("amount", DataType::String, FieldMeaning::Money)
471            .field("note", DataType::String, FieldMeaning::FreeText)
472            .field("created_at", DataType::String, FieldMeaning::CreatedAt)
473    }
474
475    #[test]
476    fn derive_crud_plan_create() {
477        let svc = crud_order_service();
478        let inputs = serde_json::json!({ "amount": "100.00", "note": "rush order" });
479        let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
480
481        let CrudPlan::Create {
482            ref table,
483            ref columns,
484            ref tenant_column,
485        } = plan
486        else {
487            panic!("expected CrudPlan::Create, got {plan:?}");
488        };
489
490        assert_eq!(table, "orders"); // resolved_table(): "order" + "s"
491
492        // User-supplied writable fields present
493        assert!(
494            columns
495                .iter()
496                .any(|(col, val)| col == "amount" && val == &serde_json::json!("100.00")),
497            "expected amount in columns"
498        );
499        assert!(
500            columns
501                .iter()
502                .any(|(col, val)| col == "note" && val == &serde_json::json!("rush order")),
503            "expected note in columns"
504        );
505
506        // Server-set initial status (SM declared → status = "draft")
507        assert!(
508            columns
509                .iter()
510                .any(|(col, val)| col == "status" && val == &serde_json::json!("draft")),
511            "expected status=draft in columns when SM exists"
512        );
513
514        // Server-injected fields NOT present (id, created_at excluded)
515        assert!(
516            !columns.iter().any(|(col, _)| col == "id"),
517            "id must not be in columns"
518        );
519        assert!(
520            !columns.iter().any(|(col, _)| col == "created_at"),
521            "created_at must not be in columns (executor injects it)"
522        );
523
524        // D-09: tenant_column is None
525        assert_eq!(tenant_column, &None);
526    }
527
528    #[test]
529    fn derive_crud_plan_create_no_sm_status_included() {
530        // Without a StateMachine, Status IS a writable input field.
531        let svc = crud_order_service_no_sm();
532        let inputs = serde_json::json!({ "amount": "50.00", "status": "active", "note": "test" });
533        let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
534
535        let CrudPlan::Create { ref columns, .. } = plan else {
536            panic!("expected CrudPlan::Create");
537        };
538
539        // Status present because no SM (exclude_sm_status = false)
540        assert!(
541            columns
542                .iter()
543                .any(|(col, val)| col == "status" && val == &serde_json::json!("active")),
544            "status must be in columns when no SM"
545        );
546    }
547
548    #[test]
549    fn derive_crud_plan_update() {
550        let svc = crud_order_service();
551        let inputs = serde_json::json!({ "id": 42, "amount": "200.00" });
552        let plan = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap();
553
554        let CrudPlan::Update {
555            ref table,
556            ref id_column,
557            ref id_value,
558            ref patch,
559            ref soft_delete_column,
560            ref tenant_column,
561        } = plan
562        else {
563            panic!("expected CrudPlan::Update, got {plan:?}");
564        };
565
566        assert_eq!(table, "orders");
567        assert_eq!(id_column, "id");
568        assert_eq!(id_value, &serde_json::json!(42));
569
570        // Only supplied field in patch
571        assert!(
572            patch
573                .iter()
574                .any(|(col, val)| col == "amount" && val == &serde_json::json!("200.00")),
575            "expected amount in patch"
576        );
577        // Unsupplied field not in patch (patch semantics)
578        assert!(
579            !patch.iter().any(|(col, _)| col == "note"),
580            "note must not be in patch (not supplied)"
581        );
582        // Status excluded because SM exists
583        assert!(
584            !patch.iter().any(|(col, _)| col == "status"),
585            "status must not be in patch when SM exists"
586        );
587        // id not in patch (server-injected / Identifier)
588        assert!(
589            !patch.iter().any(|(col, _)| col == "id"),
590            "id must not be in patch"
591        );
592
593        // soft_delete_column from resolved_soft_delete_column() = "deleted_at"
594        assert_eq!(soft_delete_column, "deleted_at");
595
596        // D-09: tenant_column is None
597        assert_eq!(tenant_column, &None);
598    }
599
600    #[test]
601    fn derive_crud_plan_delete() {
602        let svc = crud_order_service();
603        let inputs = serde_json::json!({ "id": 7 });
604        let plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap();
605
606        let CrudPlan::Delete {
607            ref table,
608            ref id_column,
609            ref id_value,
610            ref soft_delete_column,
611            ref tenant_column,
612        } = plan
613        else {
614            panic!("expected CrudPlan::Delete, got {plan:?}");
615        };
616
617        assert_eq!(table, "orders");
618        assert_eq!(id_column, "id");
619        assert_eq!(id_value, &serde_json::json!(7));
620        assert_eq!(soft_delete_column, "deleted_at");
621        assert_eq!(tenant_column, &None);
622    }
623
624    #[test]
625    fn derive_crud_plan_verb_not_enabled() {
626        // Service with creatable/updatable/deletable all false (defaults)
627        let svc = ServiceDef::new("product")
628            .mcp_write_ability("manage-products")
629            .field("id", DataType::Integer, FieldMeaning::Identifier)
630            .field("name", DataType::String, FieldMeaning::EntityName);
631
632        let inputs = serde_json::json!({ "name": "Widget" });
633
634        let err_create = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap_err();
635        assert!(
636            matches!(err_create, crate::Error::VerbNotEnabled(ref s) if s.contains("product.create")),
637            "expected VerbNotEnabled for create, got {err_create:?}"
638        );
639
640        let err_update = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap_err();
641        assert!(
642            matches!(err_update, crate::Error::VerbNotEnabled(ref s) if s.contains("product.update")),
643            "expected VerbNotEnabled for update, got {err_update:?}"
644        );
645
646        let err_delete = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap_err();
647        assert!(
648            matches!(err_delete, crate::Error::VerbNotEnabled(ref s) if s.contains("product.delete")),
649            "expected VerbNotEnabled for delete, got {err_delete:?}"
650        );
651    }
652
653    #[test]
654    fn crud_plan_serde_round_trip() {
655        let svc = crud_order_service();
656        let inputs = serde_json::json!({ "amount": "50.00" });
657
658        // Create round-trip
659        let create_plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
660        let json = serde_json::to_string(&create_plan).unwrap();
661        let back: CrudPlan = serde_json::from_str(&json).unwrap();
662        assert_eq!(create_plan, back);
663
664        // Update round-trip
665        let update_inputs = serde_json::json!({ "id": 1, "amount": "75.00" });
666        let update_plan = super::derive_crud_plan(&svc, CrudVerb::Update, &update_inputs).unwrap();
667        let json = serde_json::to_string(&update_plan).unwrap();
668        let back: CrudPlan = serde_json::from_str(&json).unwrap();
669        assert_eq!(update_plan, back);
670
671        // Delete round-trip
672        let delete_inputs = serde_json::json!({ "id": 3 });
673        let delete_plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &delete_inputs).unwrap();
674        let json = serde_json::to_string(&delete_plan).unwrap();
675        let back: CrudPlan = serde_json::from_str(&json).unwrap();
676        assert_eq!(delete_plan, back);
677    }
678
679    // WR-02: missing or null id is rejected at derivation time, not deferred to SQL.
680    #[test]
681    fn derive_crud_plan_update_missing_id_is_validation_error() {
682        let svc = crud_order_service();
683        // id absent
684        let err = super::derive_crud_plan(
685            &svc,
686            CrudVerb::Update,
687            &serde_json::json!({ "amount": "50.00" }),
688        )
689        .unwrap_err();
690        assert!(
691            matches!(err, crate::Error::Validation(_)),
692            "expected Validation error for missing id on update, got {err:?}"
693        );
694        // id explicitly null
695        let err_null = super::derive_crud_plan(
696            &svc,
697            CrudVerb::Update,
698            &serde_json::json!({ "id": null, "amount": "50.00" }),
699        )
700        .unwrap_err();
701        assert!(
702            matches!(err_null, crate::Error::Validation(_)),
703            "expected Validation error for null id on update, got {err_null:?}"
704        );
705    }
706
707    #[test]
708    fn derive_crud_plan_delete_missing_id_is_validation_error() {
709        let svc = crud_order_service();
710        // id absent
711        let err =
712            super::derive_crud_plan(&svc, CrudVerb::Delete, &serde_json::json!({})).unwrap_err();
713        assert!(
714            matches!(err, crate::Error::Validation(_)),
715            "expected Validation error for missing id on delete, got {err:?}"
716        );
717        // id explicitly null
718        let err_null =
719            super::derive_crud_plan(&svc, CrudVerb::Delete, &serde_json::json!({ "id": null }))
720                .unwrap_err();
721        assert!(
722            matches!(err_null, crate::Error::Validation(_)),
723            "expected Validation error for null id on delete, got {err_null:?}"
724        );
725    }
726
727    // ── Task 1: tenant_column derivation tests (RED → GREEN in Task 1 impl) ──
728
729    /// Build a CRUD-capable service WITH a tenant column declared.
730    fn crud_order_service_with_tenant() -> ServiceDef {
731        let machine = StateMachine::new("order_lifecycle")
732            .initial("draft")
733            .transition(Transition::new("draft", "submit", "submitted"));
734
735        ServiceDef::new("order")
736            .mcp_write_ability("manage-orders")
737            .creatable(true)
738            .updatable(true)
739            .deletable(true)
740            .tenant_column("tenant_id")
741            .state_machine(machine)
742            .field("id", DataType::Integer, FieldMeaning::Identifier)
743            .field("status", DataType::String, FieldMeaning::Status)
744            .field("amount", DataType::String, FieldMeaning::Money)
745            .field("note", DataType::String, FieldMeaning::FreeText)
746            .field("created_at", DataType::String, FieldMeaning::CreatedAt)
747    }
748
749    #[test]
750    fn derive_crud_plan_create_tenant_column() {
751        // WITH tenant column → Some(TenantColumn { column: "tenant_id" })
752        let svc = crud_order_service_with_tenant();
753        let inputs = serde_json::json!({ "amount": "100.00" });
754        let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
755
756        let CrudPlan::Create {
757            ref tenant_column, ..
758        } = plan
759        else {
760            panic!("expected CrudPlan::Create, got {plan:?}");
761        };
762        assert_eq!(
763            tenant_column,
764            &Some(TenantColumn {
765                column: "tenant_id".into()
766            }),
767            "create with tenant_column set must yield Some(TenantColumn)"
768        );
769
770        // WITHOUT tenant column → None (non-tenant projection stays unscoped)
771        let svc_no_tenant = crud_order_service();
772        let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Create, &inputs).unwrap();
773        let CrudPlan::Create {
774            ref tenant_column, ..
775        } = plan_no
776        else {
777            panic!("expected CrudPlan::Create");
778        };
779        assert_eq!(
780            tenant_column, &None,
781            "create without tenant_column must yield None"
782        );
783    }
784
785    #[test]
786    fn derive_crud_plan_update_tenant_column() {
787        // WITH tenant column → Some(TenantColumn { column: "tenant_id" })
788        let svc = crud_order_service_with_tenant();
789        let inputs = serde_json::json!({ "id": 1, "amount": "200.00" });
790        let plan = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap();
791
792        let CrudPlan::Update {
793            ref tenant_column, ..
794        } = plan
795        else {
796            panic!("expected CrudPlan::Update, got {plan:?}");
797        };
798        assert_eq!(
799            tenant_column,
800            &Some(TenantColumn {
801                column: "tenant_id".into()
802            }),
803            "update with tenant_column set must yield Some(TenantColumn)"
804        );
805
806        // WITHOUT tenant column → None
807        let svc_no_tenant = crud_order_service();
808        let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Update, &inputs).unwrap();
809        let CrudPlan::Update {
810            ref tenant_column, ..
811        } = plan_no
812        else {
813            panic!("expected CrudPlan::Update");
814        };
815        assert_eq!(
816            tenant_column, &None,
817            "update without tenant_column must yield None"
818        );
819    }
820
821    #[test]
822    fn derive_crud_plan_delete_tenant_column() {
823        // WITH tenant column → Some(TenantColumn { column: "tenant_id" })
824        let svc = crud_order_service_with_tenant();
825        let inputs = serde_json::json!({ "id": 7 });
826        let plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap();
827
828        let CrudPlan::Delete {
829            ref tenant_column, ..
830        } = plan
831        else {
832            panic!("expected CrudPlan::Delete, got {plan:?}");
833        };
834        assert_eq!(
835            tenant_column,
836            &Some(TenantColumn {
837                column: "tenant_id".into()
838            }),
839            "delete with tenant_column set must yield Some(TenantColumn)"
840        );
841
842        // WITHOUT tenant column → None
843        let svc_no_tenant = crud_order_service();
844        let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Delete, &inputs).unwrap();
845        let CrudPlan::Delete {
846            ref tenant_column, ..
847        } = plan_no
848        else {
849            panic!("expected CrudPlan::Delete");
850        };
851        assert_eq!(
852            tenant_column, &None,
853            "delete without tenant_column must yield None"
854        );
855    }
856}