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
Full Decision Record
Status
Testing — January 2025Context
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”)
| Option | Pros | Cons |
|---|---|---|
| Relational Tables | SQL queries per field, strong typing | Complex joins, migration pain for every change |
| JSON Column | Flexible, versionable, fast reads | No field-level queries, larger payloads |
| External CMS | UI for non-devs | Another service, sync complexity |
Decision
We chose recursive JSON trees stored in PostgreSQL JSONB columns, validated by Zod discriminated unions.What We’re Learning
Working well:- Schema changes don’t require migrations
- Conditional logic (expression trees) fits naturally
- Export/import forms as JSON files
- Reporting requires parsing JSON (can’t
SELECTa 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
Full Decision Record
Status
Testing — January 2025Context
Government applicants upload sensitive documents:- Photo IDs and driver’s licenses
- Tax returns and W-2s
- Medical records and disability documentation
- Bank statements
Decision
We use strictly private Supabase Storage buckets with zero public URLs.Security Model
- Upload: Anyone (authenticated or anonymous) can upload
- Storage path:
{formId}/{uuid}/{filename}— UUIDs prevent enumeration - Read: Only
service_rolecan read — no client-side access - Admin download: Server generates signed URL, returns to admin
- 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
- 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
Full Decision Record
Status
Testing — January 2025Context
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.| Option | Pros | Cons |
|---|---|---|
| Supabase Auth only | RLS works natively | No enterprise SSO, limited customization |
| WorkOS + bypass RLS | Simple, just use service role | Lose RLS benefits entirely |
| WorkOS + local users table | Best of both worlds | Extra sync complexity |
Decision
We decouple identity: WorkOS handles authentication, and we maintain a localusers table synced with WorkOS profiles.supabaseAdmin (service role) to bypass RLS, then manually enforce permissions: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
- 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
Full Decision Record
Status
Implemented — December 2024Context
Government intake platforms handle sensitive personal information. A security vulnerability could expose:- Social Security numbers
- Financial documents
- Medical records
- Immigration status
Decision
We implemented a defense-in-depth model with centralized security utilities and multiple validation layers.Security Utilities (src/lib/security.ts)
| Function | Protects 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:XSS Prevention
All user-generated content rendered as HTML uses DOMPurify: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
- 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-dompurifyin client components - SAST scanning: Aikido integration
- Documentation:
docs/guides/security.mdx
Proposed ADRs
These decisions are under consideration:| ID | Title | Status |
|---|---|---|
| ADR-005 | PDF Generation Strategy | Implemented, needs documentation |
| ADR-006 | Multi-Tenancy Model | Under discussion |
| ADR-007 | Webhook Architecture | Planned |
Back to Tech Stack
Review the technologies these decisions shaped.