Skip to main content

Architecture Decision Records

This page documents the significant architectural decisions in Terra. Each ADR explains the context, the decision, and what we’re learning.

ADR-001: Recursive Form Schema

Full Decision Record

Status

Testing — January 2025

Context

Government benefit forms are complex. A rental assistance application might include:
  • Conditional sections (“If you have dependents, list them”)
  • Repeatable groups (“Add another household member”)
  • Nested logic (“Show bank info only if direct deposit AND checking account”)
We needed to decide how to store form definitions.
OptionProsCons
Relational TablesSQL queries per field, strong typingComplex joins, migration pain for every change
JSON ColumnFlexible, versionable, fast readsNo field-level queries, larger payloads
External CMSUI for non-devsAnother service, sync complexity

Decision

We chose recursive JSON trees stored in PostgreSQL JSONB columns, validated by Zod discriminated unions.
// src/types/schema.ts (simplified)
export type FormElement =
  | TextField
  | ChoiceField
  | GroupField      // Contains nested elements
  | RepeatedField;  // Repeatable group with min/max

export type GroupField = {
  type: "group";
  elements: FormElement[];  // Recursive!
  collapsible?: boolean;
};
This is inspired by Tideswell’s architecture, which proved that JSON schemas handle government form complexity well.

What We’re Learning

Working well:
  • Schema changes don’t require migrations
  • Conditional logic (expression trees) fits naturally
  • Export/import forms as JSON files
Tradeoffs:
  • Reporting requires parsing JSON (can’t SELECT a specific field)
  • Large forms = large payloads (mitigated by server components)

Implementation

  • Schema: src/types/schema.ts
  • Logic engine: src/lib/logic-engine.ts
  • Renderer: src/components/engine/renderer.tsx

ADR-002: Private Storage Model

Full Decision Record

Status

Testing — January 2025

Context

Government applicants upload sensitive documents:
  • Photo IDs and driver’s licenses
  • Tax returns and W-2s
  • Medical records and disability documentation
  • Bank statements
These documents must never be publicly accessible. A leaked URL should not expose the file.

Decision

We use strictly private Supabase Storage buckets with zero public URLs.
-- migrations/004_storage_bucket.sql

-- Private bucket - no public access
INSERT INTO storage.buckets (id, name, public)
VALUES ('form-uploads', 'form-uploads', false);

-- ONLY service_role can read files
CREATE POLICY "Service role only read"
ON storage.objects FOR SELECT
TO service_role
USING (bucket_id = 'form-uploads');
All file access goes through signed URLs with 60-second expiry:
// Server action (admin only)
const { data } = await supabaseAdmin.storage
  .from("form-uploads")
  .createSignedUrl(path, 60);  // Expires in 60 seconds

Security Model

  1. Upload: Anyone (authenticated or anonymous) can upload
  2. Storage path: {formId}/{uuid}/{filename} — UUIDs prevent enumeration
  3. Read: Only service_role can read — no client-side access
  4. Admin download: Server generates signed URL, returns to admin
  5. Expiry: URLs expire in 60 seconds

What We’re Learning

Working well:
  • Zero public URLs means zero accidental exposure
  • Signed URLs provide auditable access patterns
  • Supabase handles the complexity
Tradeoffs:
  • Extra server hop for every file view
  • Can’t use CDN caching (URLs change constantly)

Implementation

  • Bucket setup: migrations/004_storage_bucket.sql
  • Upload component: src/components/engine/fields/files-field.tsx
  • Signed URL generation: Server actions in src/app/actions.ts

ADR-003: Identity Decoupling

Full Decision Record

Status

Testing — January 2025

Context

We use WorkOS for authentication (enterprise SSO, managed login UI). But Supabase’s Row Level Security (RLS) can’t directly use WorkOS tokens—RLS expects Supabase Auth sessions.We needed to decide how to bridge these systems.
OptionProsCons
Supabase Auth onlyRLS works nativelyNo enterprise SSO, limited customization
WorkOS + bypass RLSSimple, just use service roleLose RLS benefits entirely
WorkOS + local users tableBest of both worldsExtra sync complexity

Decision

We decouple identity: WorkOS handles authentication, and we maintain a local users table synced with WorkOS profiles.
// src/lib/auth.ts
export async function authenticateWithCode(code: string): Promise<Session> {
  const { user, accessToken } = await workos.userManagement
    .authenticateWithCode({ clientId, code });

  // Session contains WorkOS user data
  return {
    user: {
      id: user.id,        // WorkOS user ID
      email: user.email,
      firstName: user.firstName,
      // ...
    },
    accessToken,
  };
}
Server actions use supabaseAdmin (service role) to bypass RLS, then manually enforce permissions:
// Check permissions manually
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");

// Use admin client for database operations
const { data } = await supabaseAdmin
  .from("submissions")
  .select()
  .eq("user_id", session.user.id);

What We’re Learning

Working well:
  • WorkOS SSO works seamlessly (SAML, OIDC, social providers)
  • Local users table enables custom fields (role, preferences)
  • Clean separation: WorkOS = identity, Supabase = data
Tradeoffs:
  • RLS policies aren’t automatically enforced (must check permissions in code)
  • User sync can drift (mitigated by syncing on each login)
  • Service role usage requires careful auditing

Future Possibilities

This architecture enables:
  • Role-based access: Store roles in local users table
  • Audit logs: Log all service role operations
  • Directory sync: WorkOS can push org changes to our users table

Implementation

  • Auth library: src/lib/auth.ts
  • Supabase clients: src/lib/supabase.ts
  • Session middleware: src/middleware.ts
  • Permission checks: src/app/actions/team.ts

ADR-004: Defense-in-Depth Security

Full Decision Record

Status

Implemented — December 2024

Context

Government intake platforms handle sensitive personal information. A security vulnerability could expose:
  • Social Security numbers
  • Financial documents
  • Medical records
  • Immigration status
We needed a systematic approach to prevent common web vulnerabilities (OWASP Top 10).

Decision

We implemented a defense-in-depth model with centralized security utilities and multiple validation layers.

Security Utilities (src/lib/security.ts)

FunctionProtects Against
getSafeRedirectPath()Open Redirect attacks
isValidExternalUrl()Protocol injection
createSafeInternalRedirect()Cross-origin redirects
cleanFileName()Path traversal via filenames
sanitizeStoragePath()Storage path manipulation
buildSafeStoragePath()Unsafe path construction

Multi-Layer Validation

Every security-sensitive operation has at least two independent checks:
// Example: File upload has 3 layers of protection

// Layer 1: Client sanitizes filename
const safeFileName = cleanFileName(file.name);  // "../../evil.exe" → "evil.exe"

// Layer 2: Server constructs safe path
const path = buildSafeStoragePath(formId, fileId, safeFileName);

// Layer 3: Final validation before storage
const validatedPath = sanitizeStoragePath(path);  // Throws if invalid

XSS Prevention

All user-generated content rendered as HTML uses DOMPurify:
import DOMPurify from 'isomorphic-dompurify';

// Before (vulnerable)
<div dangerouslySetInnerHTML={{ __html: userContent }} />

// After (safe)
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />

What We’re Learning

Working well:
  • Centralized utilities make audits easier
  • Automated SAST (Aikido) catches new vulnerabilities in PRs
  • Defense-in-depth means individual layer failures don’t cause breaches
Tradeoffs:
  • Extra validation adds small latency (~1-2ms)
  • Must remember to use security utilities (enforced via code review)
  • Some scanners can’t trace data flow through sanitizers

Implementation

  • Security utilities: src/lib/security.ts
  • XSS sanitization: isomorphic-dompurify in client components
  • SAST scanning: Aikido integration
  • Documentation: docs/guides/security.mdx

Proposed ADRs

These decisions are under consideration:
IDTitleStatus
ADR-005PDF Generation StrategyImplemented, needs documentation
ADR-006Multi-Tenancy ModelUnder discussion
ADR-007Webhook ArchitecturePlanned

Back to Tech Stack

Review the technologies these decisions shaped.