Architecture Decisions
Architecture Decision Records (ADRs) documenting key technical decisions for the 1 OAK MLS Platform
Architecture Decision Records
Key technical decisions documented as ADRs. These capture the context, decision, and consequences for important architectural choices.
ADR-001: Bridge API Media — No $expand=Media
Status: Accepted (January 2026)
Context: Listing photos were not syncing — MediaURL was always null when fetching property data from Bridge Interactive.
Decision: Remove $expand=Media from all Bridge API queries. Media is already embedded in the Property response as a Media array without needing expansion.
Consequences:
- Media data (photos) syncs correctly with CloudFront CDN URLs
- Simpler queries (no
$expandparameter needed) - Must access photos via the
Mediaarray on each Property object, not as a separate resource
Confirmed by Bridge Interactive Support:
"Media on Bridge is returned as a Media array in the property resource not the Media resource."
See Sync Worker and Bridge API for implementation details.
ADR-002: Web App Modals — Custom Pattern, Not Radix Dialog
Status: Accepted (February 2026)
Context: Radix Dialog components from @1oak/ui render with blank/invisible content in the web app's dark mode. The dialog content portals outside the themed context.
Decision: Use a custom modal implementation in the web app instead of @1oak/ui Dialog components. The admin app continues to use Radix Dialog normally.
Pattern:
// Custom modal pattern for web app
if (!open) return null
return (
<div className="fixed inset-0 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div
className="absolute inset-0"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)', zIndex: 1 }}
onClick={handleClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
className="relative w-full max-w-md mx-4 rounded-lg border border-border bg-background p-6 shadow-xl"
style={{ zIndex: 2 }}
>
{/* Use semantic CSS variables: bg-background, text-foreground, border-border */}
</div>
</div>
)Required features for custom modals:
- Escape key handler via
useEffect+document.addEventListener('keydown', ...) - Body scroll lock:
document.body.style.overflow = 'hidden' - Backdrop click to close
- Semantic CSS variables for dark mode compatibility
Consequences:
- Two modal patterns in the codebase (Radix for admin, custom for web)
- Web app modals require manual accessibility implementation
- Consistent dark mode rendering in the web app
Affected files:
apps/web/src/components/listing-detail/listing-inquiry-modal.tsx(reference implementation)apps/web/src/components/dashboard/create-import-dialog.tsxapps/web/src/components/dashboard/add-domain-dialog.tsxapps/web/src/app/dashboard/members/invite-member-dialog.tsx
ADR-003: Team Logo — One Team Per Workspace
Status: Accepted (February 2026) Context: Real estate agents often belong to named teams within their brokerage (e.g., "The Jills Zeder Group" within Coldwell Banker Realty). Need to display team branding without over-engineering a full team management system.
Decision: One team per workspace, stored in workspace.settings.team (JSON field). No separate teams table.
Design:
TeamInfointerface:{ name?, logoUrl?, logoUrlDark? }inpackages/shared/src/types/theme.ts- Visual hierarchy: Team logo PRIMARY (100%), Brokerage logo SECONDARY (75-80%, subtle opacity)
- Collapsible UI section, collapsed by default if no team data
Consequences:
- Simple model that matches the existing workspace-per-agent structure
- No database migration needed (uses existing settings JSON field)
- The team feature is "an agent shows their team" — not a team management system
- Future broker support (a brokerage managing multiple agents) would require a different architecture
ADR-004: Multi-Tenant First — workspace_id on Every Table
Status: Accepted (founding decision) Context: The platform serves multiple real estate agents, each with their own listings, branding, and configuration. Data isolation is critical.
Decision: Every data table includes a workspace_id column as the partition key. No exceptions.
Consequences:
- All queries must include
workspace_id— no cross-tenant data leakage possible - RLS policies can enforce workspace boundaries at the database level
- Typesense collections are per-workspace for search isolation
- Storage paths are prefixed with workspace ID
- Slightly more complex queries but absolute tenant isolation
ADR-005: Source of Truth — Postgres Canonical, Typesense Derived
Status: Accepted (founding decision) Context: The platform needs both reliable persistent storage (for listings, media, compliance data) and fast faceted search (for the client-facing property search).
Decision: Supabase Postgres is the canonical data store. Typesense is a derived search index that can be rebuilt from Postgres at any time.
Sync flow:
Bridge API → Postgres (canonical) → Typesense (derived index)Consequences:
- Typesense can be rebuilt from scratch without data loss
- Schema changes happen in Postgres first, then Typesense schema is updated
- Listings are upserted to Postgres, then indexed to Typesense in the same sync run
- If Typesense is unavailable, listings are still stored — they just aren't searchable until re-indexed
- Slight latency between Postgres write and Typesense availability (typically < 1 second)
ADR-006: Incremental Sync via ModificationTimestamp
Status: Accepted (founding decision) Context: Full MLS syncs can involve thousands of listings. Syncing everything on every run is wasteful and slow.
Decision: Use the RESO ModificationTimestamp field to pull only listings modified since the last sync checkpoint.
Implementation:
- Store
last_synced_atonmls_connections(orimport_tasksfor task-level sync) - On incremental sync:
$filter=ModificationTimestamp gt {last_synced_at} - On success: update checkpoint to the latest
ModificationTimestampin the batch - On failure: do not update checkpoint — next run retries the same range
Consequences:
- Typical incremental syncs process 0-50 listings instead of 2,000+
- Checkpoint provides exactly-once-or-more semantics (safe to re-process)
- Full sync mode is available as a manual override when needed