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#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "lowercase")]
472pub enum Role {
473    User,
474    Assistant,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478#[serde(tag = "type", rename_all = "snake_case")]
479pub enum ContentBlock {
480    Text { text: String },
481    ToolUse { id: String, name: String, input: serde_json::Value },
482    ToolResult { tool_use_id: String, content: String, #[serde(default)] is_error: bool },
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct ChatMessage {
487    pub role: Role,
488    pub content: Vec<ContentBlock>,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct ToolDef {
493    pub name: String,
494    pub description: String,
495    pub input_schema: serde_json::Value,
496}
497
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    #[test]
503    fn index_column_deserializes_bare_string_and_spec() {
504        let json = r#"{"entityName":"program","fields":[],"indexes":[{"name":"test","columns":["slug"],"unique":true}]}"#;
505        let e: EntityContract = serde_json::from_str(json).expect("deser failed");
506        assert_eq!(e.indexes.len(), 1, "indexes should parse");
507        assert_eq!(e.indexes[0].columns.len(), 1, "columns should parse");
508        match &e.indexes[0].columns[0] {
509            IndexColumn::Name(n) => assert_eq!(n, "slug"),
510            other => panic!("expected Name('slug'), got {:?}", other),
511        }
512    }
513
514    // Regression for the snake_case field keys (see FieldContract): they must
515    // survive deser AND a serialize round-trip, else the stored manifest loses
516    // them again.
517    #[test]
518    fn field_contract_preserves_snake_case_keys() {
519        let json = r#"{"name":"status","type":"text","enum_values":["draft","active"],"on_delete":"set_null"}"#;
520        let f: FieldContract = serde_json::from_str(json).expect("deser failed");
521        assert_eq!(f.enum_values.as_deref(), Some(&["draft".to_string(), "active".to_string()][..]));
522        assert_eq!(f.on_delete, Some(OnDeletePolicy::SetNull));
523
524        // Round-trip: re-serialize then deser — values must not be lost (proves
525        // serialization is snake_case too, so the stored manifest stays faithful).
526        let round: FieldContract =
527            serde_json::from_value(serde_json::to_value(&f).unwrap()).expect("round-trip failed");
528        assert_eq!(round.enum_values, f.enum_values, "enum_values lost on round-trip");
529        assert_eq!(round.on_delete, f.on_delete, "on_delete lost on round-trip");
530    }
531
532    #[test]
533    fn entity_parses_declarative_checks() {
534        let json = r#"{"entityName":"enrollment","fields":[],
535            "checks":[{"name":"enrollment_dates_chk","expr":"exit_date IS NULL OR exit_date >= intake_date"},
536                      {"expr":"x <> y"}]}"#;
537        let e: EntityContract = serde_json::from_str(json).expect("deser failed");
538        assert_eq!(e.checks.len(), 2);
539        assert_eq!(e.checks[0].name.as_deref(), Some("enrollment_dates_chk"));
540        assert_eq!(e.checks[1].name, None, "unnamed check keeps name None (auto-derived later)");
541        assert_eq!(e.checks[1].expr, "x <> y");
542    }
543}