> ## Documentation Index
> Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Schema Design

> How Terra represents forms as recursive JSON trees with Zod validation

# Schema Design

> Forms are trees, not tables. This design decision shapes everything.

Government benefit forms have deeply nested conditional logic: "If you have dependents, list them. If any dependent has a disability, show the accommodation questions." Terra represents forms as **recursive JSON trees** stored in PostgreSQL JSONB columns, validated at runtime by Zod schemas.

***

## Why JSON Trees?

We considered three approaches:

| Approach          | Pros                                 | Cons                                       |
| ----------------- | ------------------------------------ | ------------------------------------------ |
| Relational tables | SQL queries per field, strong typing | Complex joins, migrations for every change |
| JSON columns      | Flexible, versionable, fast reads    | No field-level SQL queries                 |
| External CMS      | UI for non-developers                | Another service, sync complexity           |

We chose JSON for several reasons:

1. **Schema changes don't require migrations** — add a new field type by updating TypeScript, not SQL
2. **Conditional logic fits naturally** — expression trees nest inside the form tree
3. **Export/import is trivial** — the form is a single JSON document
4. **Versioning is simple** — store draft and published schemas side by side

The tradeoff: you can't query individual fields with SQL. Reporting requires parsing JSON. But for our use case—rendering forms and processing submissions—the tree structure is a perfect fit.

***

## The FormElement Type

Every field in a form is a `FormElement`. This is a discriminated union—TypeScript's way of representing "one of these types":

```typescript theme={null}
// src/types/schema.ts
export type FormElement =
  | TextField
  | NumberField
  | DateField
  | ChoiceField
  | AddressField
  | FilesField
  | InfoField
  | SectionHeader
  | DividerField
  | BankField
  | PlaidIdVerificationField
  | PlaidBankVerificationField
  | SignatureField
  | ImageField
  | LinkField
  | StatementField
  | LanguagePreferenceField
  | GroupField      // Contains nested elements (recursive!)
  | RepeatedField;  // Repeatable group with min/max
```

The `type` property discriminates which variant you have:

```typescript theme={null}
function renderField(element: FormElement) {
  switch (element.type) {
    case "text":
      return <TextInput {...element} />;
    case "choice":
      return <ChoiceInput {...element} />;
    case "group":
      // Recursive! Groups contain elements
      return <Group>{element.elements.map(renderField)}</Group>;
    // ...
  }
}
```

***

## Base Element Properties

Every element shares these properties:

```typescript theme={null}
const BaseElementSchema = z.object({
  id: z.string(),                        // Unique identifier
  label: I18nStringSchema,               // Display label (multi-language)
  helpText: I18nStringSchema.optional(), // Helper text below field
  visibility: VisibilityRuleSchema.optional(), // @deprecated
  logic: LogicConditionSchema.optional(),      // Expression tree
  logicAction: z.enum(["show", "hide"]).optional(),
  validation: ValidationRuleSchema.optional(),
  layout: LayoutOptionsSchema.optional(),
});
```

### I18nString: Multi-Language by Design

Every user-facing string is an `I18nString`—a map from locale codes to translated text:

```typescript theme={null}
export type I18nString = Record<string, string>;

// Example
const label: I18nString = {
  en: "Full Name",
  es: "Nombre Completo",
  zh: "全名",
};
```

This isn't a bolted-on translation layer. Multi-language is in the data model from the start.

### Validation Rules

Field-level validation with localized error messages:

```typescript theme={null}
const ValidationRuleSchema = z.object({
  required: z.boolean().optional(),
  minLength: z.number().optional(),
  maxLength: z.number().optional(),
  min: z.number().optional(),
  max: z.number().optional(),
  pattern: z.string().optional(),          // Regex
  message: I18nStringSchema.optional(),    // Custom error message
  numberType: z.enum([
    "any",       // Any number
    "whole",     // 0, 1, 2, 3...
    "integer",   // ...-2, -1, 0, 1, 2...
    "decimal",   // 1.5, 3.14, -2.7
    "monetary",  // 1234.56 (2 decimal places)
  ]).optional(),
  requireMatch: z.boolean().optional(),    // Double-entry confirmation
  minAge: z.number().optional(),           // For date fields
  maxAge: z.number().optional(),
});
```

***

## Field Types Deep Dive

### Text Fields

```typescript theme={null}
const TextFieldSchema = BaseElementSchema.extend({
  type: z.literal("text"),
  placeholder: I18nStringSchema.optional(),
  inputType: z.enum(["text", "email", "tel", "url"]).optional(),
  multiline: z.boolean().optional(), // textarea vs input
});
```

The `inputType` maps to HTML input types, enabling mobile keyboards (email shows @, tel shows numpad).

### Choice Fields

One schema handles radios, checkboxes, and dropdowns:

```typescript theme={null}
const ChoiceFieldSchema = BaseElementSchema.extend({
  type: z.literal("choice"),
  options: z.array(z.object({
    value: z.string(),
    label: I18nStringSchema,
  })),
  multiple: z.boolean().optional(),        // checkbox vs radio
  optionLayout: z.enum(["vertical", "horizontal"]).optional(),
  variant: z.enum(["list", "cards"]).optional(),  // cards = large buttons
  renderAs: z.enum(["radio", "checkbox", "dropdown"]).optional(),
});
```

### Address Fields (Composite)

Address is a composite field with built-in Smarty Streets integration:

```typescript theme={null}
const AddressFieldSchema = BaseElementSchema.extend({
  type: z.literal("address"),
  includeUnit: z.boolean().optional(),
});
```

When rendered, this expands to multiple inputs (street, city, state, zip) with optional autocomplete.

### Group Fields (Recursive)

Groups contain other elements, enabling nesting:

```typescript theme={null}
export type GroupField = BaseElement & {
  type: "group";
  elements: FormElement[];  // Recursive!
  collapsible?: boolean;
};
```

This is how conditional sections work: a group with visibility logic.

### Repeated Fields

For "Add another household member" patterns:

```typescript theme={null}
export type RepeatedField = BaseElement & {
  type: "repeated";
  elements: FormElement[];      // Template for each item
  buttonLabel: I18nString;      // "Add Member"
  itemTitle: I18nString;        // "Household Member"
  min: number;                  // Minimum required
  max: number;                  // Maximum allowed
  summaryFields?: string[];     // Fields to show in collapsed view
};
```

Each item in the repeated group gets its own copy of the template elements.

***

## Form Structure

Forms contain pages, pages contain elements:

```typescript theme={null}
const FormPageSchema = z.object({
  id: z.string(),
  title: I18nStringSchema.optional(),
  description: I18nStringSchema.optional(),
  elements: z.array(FormElementSchema),
});

const FormSchemaDefinition = z.object({
  id: z.string(),
  version: z.string().optional(),
  title: I18nStringSchema,
  description: I18nStringSchema.optional(),
  pages: z.array(FormPageSchema),
});
```

A complete form schema looks like:

```json theme={null}
{
  "id": "rental-assistance",
  "version": "2.0",
  "title": { "en": "Rental Assistance Application" },
  "pages": [
    {
      "id": "personal",
      "title": { "en": "Personal Information" },
      "elements": [
        {
          "id": "full-name",
          "type": "text",
          "label": { "en": "Full Name" },
          "validation": { "required": true }
        },
        {
          "id": "dob",
          "type": "date",
          "label": { "en": "Date of Birth" },
          "validation": { "required": true, "minAge": 18 }
        }
      ]
    },
    {
      "id": "household",
      "title": { "en": "Household Members" },
      "elements": [
        {
          "id": "has-dependents",
          "type": "choice",
          "label": { "en": "Do you have dependents?" },
          "options": [
            { "value": "yes", "label": { "en": "Yes" } },
            { "value": "no", "label": { "en": "No" } }
          ]
        },
        {
          "id": "dependents",
          "type": "repeated",
          "label": { "en": "Dependents" },
          "logic": {
            "id": "show-dependents",
            "type": "rule",
            "fieldId": "has-dependents",
            "operator": "eq",
            "value": "yes"
          },
          "buttonLabel": { "en": "Add Dependent" },
          "itemTitle": { "en": "Dependent" },
          "min": 1,
          "max": 10,
          "elements": [
            {
              "id": "name",
              "type": "text",
              "label": { "en": "Name" }
            },
            {
              "id": "relationship",
              "type": "choice",
              "label": { "en": "Relationship" },
              "options": [
                { "value": "child", "label": { "en": "Child" } },
                { "value": "spouse", "label": { "en": "Spouse" } }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

***

## Zod Runtime Validation

The schema types aren't just for TypeScript—they validate at runtime:

```typescript theme={null}
import { FormSchemaDefinition } from "@/types/schema";

// Parse and validate JSON from database
function loadFormSchema(json: unknown): FormSchema {
  return FormSchemaDefinition.parse(json);
}

// This throws if the JSON doesn't match the schema
try {
  const schema = loadFormSchema(databaseRow.draft_schema);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error("Invalid schema:", error.issues);
  }
}
```

This catches:

* Missing required fields
* Wrong types (string instead of array)
* Invalid enum values
* Malformed nested structures

***

## Database Storage

The `forms` table stores schemas as JSONB:

```sql theme={null}
CREATE TABLE forms (
  id UUID PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,
  status TEXT NOT NULL DEFAULT 'draft',

  -- Schema storage
  draft_schema JSONB,      -- Current working version
  published_schema JSONB,  -- What applicants see

  -- Metadata
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
```

**Draft vs Published**: Admins edit `draft_schema`. Publishing copies draft to `published_schema`. This enables preview without affecting live forms.

***

## Extending the Schema

To add a new field type:

1. **Define the schema** in `src/types/schema.ts`:
   ```typescript theme={null}
   export const RatingFieldSchema = BaseElementSchema.extend({
     type: z.literal("rating"),
     maxStars: z.number().default(5),
   });
   ```

2. **Add to the union**:
   ```typescript theme={null}
   export type FormElement =
     | TextField
     | ...
     | RatingField;  // Add here
   ```

3. **Add to FormElementSchema**:
   ```typescript theme={null}
   export const FormElementSchema = z.lazy(() =>
     z.union([
       TextFieldSchema,
       ...
       RatingFieldSchema,  // Add here
     ])
   );
   ```

4. **Create the renderer** in `src/components/engine/fields/`:
   ```tsx theme={null}
   export function RatingField({ element, value, onChange }) {
     return (
       <div className="flex gap-1">
         {Array.from({ length: element.maxStars }).map((_, i) => (
           <Star
             key={i}
             filled={i < value}
             onClick={() => onChange(i + 1)}
           />
         ))}
       </div>
     );
   }
   ```

5. **Register in the registry**:
   ```typescript theme={null}
   // src/components/engine/registry.tsx
   export const fieldRenderers: Record<string, FieldRenderer> = {
     text: TextFieldRenderer,
     ...
     rating: RatingFieldRenderer,
   };
   ```

No migrations. No database changes. The new field type works immediately.

***

## Trade-offs

### What Works Well

* **Rapid iteration** — new field types in minutes
* **Complex nesting** — groups and repeats compose naturally
* **Portable** — export/import as JSON files
* **Version control** — JSON diffs are readable

### What's Harder

* **Reporting** — can't `SELECT` a specific field across forms
* **Large schemas** — entire form loaded for every operation
* **Migrations** — need custom code to transform existing schemas

For our use case (rendering forms, processing submissions), the benefits far outweigh the costs.

***

## Related

<CardGroup cols={2}>
  <Card title="Logic Engine" icon="code-branch" href="/core-systems/forms/logic-engine">
    How visibility rules are evaluated
  </Card>

  <Card title="Field Types" icon="rectangle-list" href="/core-systems/forms/field-types">
    Complete reference for all 18+ field types
  </Card>
</CardGroup>
