Skip to main content

rootcx_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as JsonValue;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct OsStatus {
6    pub runtime: RuntimeStatus,
7    pub postgres: PostgresStatus,
8    pub forge: ForgeStatus,
9}
10
11impl OsStatus {
12    pub fn offline() -> Self {
13        Self {
14            runtime: RuntimeStatus { version: String::new(), state: ServiceState::Offline },
15            postgres: PostgresStatus { state: ServiceState::Offline, port: None, data_dir: None },
16            forge: ForgeStatus { state: ServiceState::Offline, port: None },
17        }
18    }
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ForgeStatus {
23    pub state: ServiceState,
24    pub port: Option<u16>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RuntimeStatus {
29    pub version: String,
30    pub state: ServiceState,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PostgresStatus {
35    pub state: ServiceState,
36    pub port: Option<u16>,
37    pub data_dir: Option<String>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum ServiceState {
43    Online,
44    Offline,
45    Starting,
46    Stopping,
47    Error,
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum AppType {
53    #[default]
54    App,
55    Integration,
56    Agent,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct ActionDefinition {
62    pub id: String,
63    pub name: String,
64    #[serde(default)]
65    pub description: String,
66    #[serde(default)]
67    pub input_schema: Option<JsonValue>,
68    #[serde(default)]
69    pub output_schema: Option<JsonValue>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct AppManifest {
75    pub app_id: String,
76    pub name: String,
77    #[serde(default = "default_version")]
78    pub version: String,
79    #[serde(default)]
80    pub description: String,
81    #[serde(default, rename = "type")]
82    pub app_type: AppType,
83    #[serde(default)]
84    pub permissions: Option<PermissionsContract>,
85    #[serde(default)]
86    pub data_contract: Vec<EntityContract>,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub actions: Vec<ActionDefinition>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub config_schema: Option<JsonValue>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub user_auth: Option<JsonValue>,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub webhooks: Vec<WebhookDefinition>,
95    /// Free-form usage instructions surfaced to AI via list_integrations tool
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub instructions: Option<String>,
98    /// Trigger: auto-invoke this agent on entity events
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub trigger: Option<TriggerConfig>,
101    /// Declarative cron schedules synced on deploy
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub crons: Vec<CronDefinition>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub icon: Option<String>,
106    /// Public-access surface. Routes listed here bypass Identity. RPCs that
107    /// declare `scope` additionally require a share token whose context
108    /// matches the request body on the listed keys.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub public: Option<PublicSurface>,
111}
112
113/// Declarative public-access surface for an app.
114///
115/// Anything listed here is reachable without an Authorization header.
116/// Anything **not** listed retains the default JWT-required behavior.
117///
118/// See `core/src/extensions/sharing/` for the runtime enforcement.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct PublicSurface {
122    /// Custom RPCs exposed publicly. If `scope` is non-empty, the request
123    /// must carry a share token whose `context` matches the request body on
124    /// every listed key.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub rpcs: Vec<PublicRpc>,
127    /// CRUD collections exposed publicly with the listed actions.
128    /// Allowed actions: "list", "read", "create", "update", "delete".
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub collections: Vec<PublicCollection>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct PublicRpc {
136    pub name: String,
137    /// Keys to enforce-match between the share token's `context` and the
138    /// request body. Empty `scope` means anonymous access (no share token
139    /// required). Non-empty means a share token IS required and the listed
140    /// keys MUST match exactly.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub scope: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct PublicCollection {
148    pub entity: String,
149    /// Subset of CRUD actions exposed: "list", "read", "create", "update", "delete".
150    pub actions: Vec<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct CronDefinition {
156    pub name: String,
157    pub schedule: String,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub timezone: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub method: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub payload: Option<JsonValue>,
164    #[serde(default = "default_overlap_policy")]
165    pub overlap_policy: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(untagged)]
170pub enum WebhookDefinition {
171    Simple(String),
172    #[serde(rename_all = "camelCase")]
173    Full { name: String, #[serde(default = "default_post")] method: String },
174}
175
176fn default_post() -> String { "POST".into() }
177
178impl WebhookDefinition {
179    pub fn name(&self) -> &str {
180        match self {
181            Self::Simple(s) => s.as_str(),
182            Self::Full { name, .. } => name.as_str(),
183        }
184    }
185    pub fn method(&self) -> &str {
186        match self {
187            Self::Simple(_) => "POST",
188            Self::Full { method, .. } => method.as_str(),
189        }
190    }
191}
192
193fn default_overlap_policy() -> String { "skip".into() }
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct TriggerConfig {
198    pub app_id: String,
199    pub entity: String,
200    pub on: Vec<String>,
201}
202
203fn default_version() -> String {
204    "0.0.1".to_string()
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct EntityContract {
210    pub entity_name: String,
211    pub fields: Vec<FieldContract>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub identity_kind: Option<String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub identity_key: Option<String>,
216    /// Declarative secondary indexes (reconciled by name against pg_indexes at
217    /// deploy). Covers the Prisma/Drizzle index surface: composite, unique,
218    /// partial, functional, method, operator class, sort/nulls order.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub indexes: Vec<IndexContract>,
221    /// Declarative table-level CHECK constraints (reconciled by tag against
222    /// pg_constraint at deploy, exactly like `indexes`). Arbitrary boolean SQL
223    /// expressions: multi-column, conditional, format. Covers the Drizzle
224    /// `check()` / Atlas `check {}` surface.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub checks: Vec<CheckContract>,
227}
228
229/// A declarative index. Mirrors what Prisma/Drizzle let you declare for
230/// PostgreSQL. `where`/`expr`/`ops` are SQL fragments (bounded, not full
231/// statements); `name` is auto-derived when omitted.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct IndexContract {
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub name: Option<String>,
237    pub columns: Vec<IndexColumn>,
238    #[serde(default)]
239    pub unique: bool,
240    /// Index method: btree (default) | hash | gist | gin | spgist | brin.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub using: Option<String>,
243    /// Partial-index predicate (the `WHERE ...` clause).
244    #[serde(rename = "where", default, skip_serializing_if = "Option::is_none")]
245    pub where_clause: Option<String>,
246    /// Storage parameters, e.g. {"fillfactor":"70"}.
247    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
248    pub with: std::collections::BTreeMap<String, String>,
249}
250
251/// An index element: either a bare column name, or a spec carrying a column OR
252/// expression plus sort/nulls/operator-class.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(untagged)]
255pub enum IndexColumn {
256    Name(String),
257    Spec(IndexColumnSpec),
258}
259
260/// A declarative table-level CHECK. `expr` is an arbitrary SQL boolean fragment
261/// (bounded, not a full statement); `name` is auto-derived when omitted. The
262/// core reconciles it by a hash of this DECLARED spec — never by comparing to
263/// Postgres's normalized stored definition — so there is no rewrite-churn.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct CheckContract {
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub name: Option<String>,
268    pub expr: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct IndexColumnSpec {
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub column: Option<String>,
276    /// Functional-index expression (mutually exclusive with `column`).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub expr: Option<String>,
279    /// asc | desc.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub sort: Option<String>,
282    /// first | last.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub nulls: Option<String>,
285    /// Operator class, e.g. gin_trgm_ops.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub ops: Option<String>,
288}
289
290// NOTE: no `rename_all = "camelCase"` here — at the FIELD level the manifest
291// format is snake_case (`enum_values`, `on_delete`, …). camelCase silently
292// dropped those keys at deser (→ null), so no enum CHECK constraints or FK
293// delete rules were ever generated. Snake_case mirrors the real format.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct FieldContract {
296    pub name: String,
297    #[serde(rename = "type")]
298    pub field_type: String,
299    #[serde(default)]
300    pub required: bool,
301    #[serde(default)]
302    pub default_value: Option<JsonValue>,
303    #[serde(default)]
304    pub enum_values: Option<Vec<String>>,
305    #[serde(default)]
306    pub references: Option<FieldReference>,
307    #[serde(default)]
308    pub is_primary_key: Option<bool>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub on_delete: Option<OnDeletePolicy>,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "snake_case")]
315pub enum OnDeletePolicy {
316    Cascade,
317    Restrict,
318    SetNull,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct FieldReference {
323    pub entity: String,
324    pub field: String,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase")]
329pub struct InstalledApp {
330    pub id: String,
331    pub name: String,
332    pub version: String,
333    pub status: String,
334    #[serde(rename = "type", default)]
335    pub app_type: AppType,
336    pub entities: Vec<String>,
337    #[serde(default)]
338    pub has_frontend: bool,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub icon: Option<String>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct PermissionsContract {
346    #[serde(default)]
347    pub permissions: Vec<PermissionDeclaration>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct PermissionDeclaration {
352    pub key: String,
353    #[serde(default)]
354    pub description: String,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct SchemaChange {
359    pub entity: String,
360    pub change_type: String,
361    pub column: String,
362    pub detail: Option<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct SchemaVerification {
367    pub compliant: bool,
368    pub changes: Vec<SchemaChange>,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "lowercase")]
373pub enum ProviderType {
374    Anthropic,
375    OpenAI,
376    Bedrock,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381pub struct AgentDefinition {
382    pub name: String,
383    #[serde(default)]
384    pub description: Option<String>,
385    #[serde(default)]
386    pub system_prompt: Option<String>,
387    #[serde(default)]
388    pub memory: Option<AgentMemory>,
389    #[serde(default)]
390    pub limits: Option<AgentLimits>,
391    #[serde(default)]
392    pub supervision: Option<SupervisionConfig>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct AgentMemory {
397    pub enabled: bool,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct AgentLimits {
403    #[serde(default)]
404    pub max_turns: Option<u32>,
405    #[serde(default)]
406    pub max_context_tokens: Option<u64>,
407    #[serde(default)]
408    pub keep_recent_messages: Option<u32>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct SupervisionConfig {
414    pub mode: SupervisionMode,
415    #[serde(default)]
416    pub policies: Vec<SupervisionPolicy>,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "lowercase")]
421pub enum SupervisionMode {
422    Autonomous,
423    Supervised,
424    Strict,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct SupervisionPolicy {
430    pub action: String,
431    #[serde(default)]
432    pub entity: Option<String>,
433    #[serde(default)]
434    pub requires: Option<String>,
435    #[serde(default)]
436    pub rate_limit: Option<RateLimit>,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct RateLimit {
441    pub max: u32,
442    pub window: String,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase")]
447pub struct McpServerConfig {
448    pub name: String,
449    pub transport: McpTransport,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(tag = "type", rename_all = "lowercase")]
454pub enum McpTransport {
455    Stdio { command: String, #[serde(default)] args: Vec<String> },
456    Http { url: String, #[serde(default)] headers: std::collections::HashMap<String, String> },
457    #[deprecated = "use Http"]
458    Sse { url: String, #[serde(default)] headers: std::collections::HashMap<String, String> },
459    Cli { install: String },
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct ToolDescriptor {
465    pub name: String,
466    pub description: String,
467    pub input_schema: serde_json::Value,
468}
469
470// ── Workflow types ────────────────────────────────────────────────────
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct WorkflowGraph {
475    pub nodes: Vec<WorkflowNode>,
476    pub edges: Vec<WorkflowEdge>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct WorkflowNode {
482    pub id: String,
483    pub kind: WorkflowNodeKind,
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub label: Option<String>,
486    #[serde(default)]
487    pub params: JsonValue,
488    #[serde(default)]
489    pub position: [f32; 2],
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
494pub enum WorkflowNodeKind {
495    Trigger { trigger: TriggerKind },
496    Tool { tool_name: String },
497    Control { control: ControlKind },
498    Code,
499    SubWorkflow { workflow_id: String },
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub enum TriggerKind {
505    Manual,
506    Schedule,
507    Webhook,
508    RecordChange,
509    Channel,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514pub enum ControlKind {
515    If,
516    Switch,
517    Merge,
518    Set,
519    Loop,
520    Wait,
521    Stop,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
525#[serde(rename_all = "camelCase")]
526pub struct WorkflowEdge {
527    pub from: String,
528    pub to: String,
529    #[serde(default)]
530    pub from_output: u8,
531    #[serde(default)]
532    pub to_input: u8,
533}
534
535/// A single data item flowing between nodes. Mirrors n8n's INodeExecutionData.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct Item {
538    pub json: JsonValue,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(rename_all = "camelCase")]
543pub enum WorkflowExecutionStatus {
544    Queued,
545    Running,
546    Succeeded,
547    Failed,
548    Canceled,
549}
550
551impl WorkflowExecutionStatus {
552    pub fn as_str(self) -> &'static str {
553        match self {
554            Self::Queued => "queued",
555            Self::Running => "running",
556            Self::Succeeded => "succeeded",
557            Self::Failed => "failed",
558            Self::Canceled => "canceled",
559        }
560    }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(rename_all = "camelCase")]
565pub enum WorkflowNodeRunStatus {
566    Pending,
567    Running,
568    Succeeded,
569    Failed,
570    Skipped,
571}
572
573impl WorkflowNodeRunStatus {
574    pub fn as_str(self) -> &'static str {
575        match self {
576            Self::Pending => "pending",
577            Self::Running => "running",
578            Self::Succeeded => "succeeded",
579            Self::Failed => "failed",
580            Self::Skipped => "skipped",
581        }
582    }
583}
584
585// ── LLM types ────────────────────────────────────────────────────────
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(rename_all = "lowercase")]
589pub enum Role {
590    User,
591    Assistant,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595#[serde(tag = "type", rename_all = "snake_case")]
596pub enum ContentBlock {
597    Text { text: String },
598    ToolUse { id: String, name: String, input: serde_json::Value },
599    ToolResult { tool_use_id: String, content: String, #[serde(default)] is_error: bool },
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct ChatMessage {
604    pub role: Role,
605    pub content: Vec<ContentBlock>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct ToolDef {
610    pub name: String,
611    pub description: String,
612    pub input_schema: serde_json::Value,
613}
614
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    #[test]
620    fn index_column_deserializes_bare_string_and_spec() {
621        let json = r#"{"entityName":"program","fields":[],"indexes":[{"name":"test","columns":["slug"],"unique":true}]}"#;
622        let e: EntityContract = serde_json::from_str(json).expect("deser failed");
623        assert_eq!(e.indexes.len(), 1, "indexes should parse");
624        assert_eq!(e.indexes[0].columns.len(), 1, "columns should parse");
625        match &e.indexes[0].columns[0] {
626            IndexColumn::Name(n) => assert_eq!(n, "slug"),
627            other => panic!("expected Name('slug'), got {:?}", other),
628        }
629    }
630
631    // Regression for the snake_case field keys (see FieldContract): they must
632    // survive deser AND a serialize round-trip, else the stored manifest loses
633    // them again.
634    #[test]
635    fn field_contract_preserves_snake_case_keys() {
636        let json = r#"{"name":"status","type":"text","enum_values":["draft","active"],"on_delete":"set_null"}"#;
637        let f: FieldContract = serde_json::from_str(json).expect("deser failed");
638        assert_eq!(f.enum_values.as_deref(), Some(&["draft".to_string(), "active".to_string()][..]));
639        assert_eq!(f.on_delete, Some(OnDeletePolicy::SetNull));
640
641        // Round-trip: re-serialize then deser — values must not be lost (proves
642        // serialization is snake_case too, so the stored manifest stays faithful).
643        let round: FieldContract =
644            serde_json::from_value(serde_json::to_value(&f).unwrap()).expect("round-trip failed");
645        assert_eq!(round.enum_values, f.enum_values, "enum_values lost on round-trip");
646        assert_eq!(round.on_delete, f.on_delete, "on_delete lost on round-trip");
647    }
648
649    // The workflow editor speaks camelCase. WorkflowNodeKind is a `tag = "type"`
650    // enum, and `rename_all` alone renames only the variants — fields inside
651    // struct variants (tool_name, workflow_id) need `rename_all_fields`. Dropping
652    // it compiles cleanly but 422s on save. Guards both directions of the exact
653    // wire shapes the editor sends and reads back.
654    #[test]
655    fn node_kind_round_trips_camelcase_wire() {
656        let cases = [
657            ("tool", serde_json::json!({"type": "tool", "toolName": "query_data"})),
658            ("trigger", serde_json::json!({"type": "trigger", "trigger": "recordChange"})),
659            ("control", serde_json::json!({"type": "control", "control": "if"})),
660            ("subWorkflow", serde_json::json!({"type": "subWorkflow", "workflowId": "wf-1"})),
661        ];
662        for (label, wire) in &cases {
663            let kind: WorkflowNodeKind = serde_json::from_value(wire.clone())
664                .unwrap_or_else(|e| panic!("[{label}] wire shape rejected: {e}"));
665            assert_eq!(&serde_json::to_value(&kind).unwrap(), wire,
666                "[{label}] re-serialized shape drifted from the editor's wire");
667        }
668    }
669
670    #[test]
671    fn entity_parses_declarative_checks() {
672        let json = r#"{"entityName":"enrollment","fields":[],
673            "checks":[{"name":"enrollment_dates_chk","expr":"exit_date IS NULL OR exit_date >= intake_date"},
674                      {"expr":"x <> y"}]}"#;
675        let e: EntityContract = serde_json::from_str(json).expect("deser failed");
676        assert_eq!(e.checks.len(), 2);
677        assert_eq!(e.checks[0].name.as_deref(), Some("enrollment_dates_chk"));
678        assert_eq!(e.checks[1].name, None, "unnamed check keeps name None (auto-derived later)");
679        assert_eq!(e.checks[1].expr, "x <> y");
680    }
681}