How Complied is put together
Complied is a multi-tenant lead-paint compliance OS. Two internally owned organizations run on it today:
- SecureEnv (
secureenv) — inspections (XRF, dust wipe, paint chip). - Abated (
abated) — abatement.
Each is a tenant: its own staff, clients, buildings, projects, documents, invoices, and branding. Isolation is Postgres RLS on tenant_id, not a separate database per customer.
The product's job, for a tenant:
- Ingest public NYC agency data onto buildings.
- Turn a trigger into a project (violation, obligation, or occupant request).
- Decide a resolution path per linked HPD order, Work the derived field tracks, File the paperwork, Money the invoice.
- Capture field work, generate paperwork, assemble an HPD filing package, invoice.
v1 ships one compliance program: nyc-lead-paint (HPD primary, DOHMH participating). Other agencies' data is ingested for intelligence (a building's full risk picture). It does not become billable workflow until someone writes another program.
Honest capability status (what actually works end-to-end today, including silent $0 failures): docs/LIFECYCLE_ASSESSMENT.md. This folder describes how the shipped system is put together; that file is the pass/fail record.
Vocabulary (do not mix these up)
| Word | Meaning here |
|---|---|
| Tenant | White-label customer org (SecureEnv, Abated). Never a resident. |
| Occupant | Person living in a unit. NYC law calls this a "tenant"; we do not. |
| Client | A property owner / managing agent of a tenant. Logs into /portal/*. |
| Platform operator | Complied staff above all tenants. Table exists; no HQ UI shipped. |
| Building / unit | Citywide public registry (BIN / apartment). Not owned by a tenant. |
| Tenant building | "We service this BIN, for this client." The tracking layer. |
| Public event | One agency record (violation, complaint, hearing, lien, 311, …). |
| Project | One unit of billable work. Always has a program and an origin. |
| Inspection | One scheduled field visit on a project (a project can have many). |
| Decision | The chosen resolution path for one linked HPD order (project_order_decisions). |
| Track | A derived workstream on a project (services + field-visit tasks), computed from decisions. |
Hierarchy
Tenant
├── Staff profiles (permission bundles)
├── Clients → client_users (portal logins)
├── tenant_buildings → buildings (shared) → units (shared)
└── Projects (origin + program + phase-as-label)
├── project_event_links, project_order_decisions, collaborators
├── inspections → inspection_events → XRF / samples / CoC / floor plans
└── documents → document_orders, filing_packages, invoices
A project always has building_id. It may have unit_id (apartment work vs common-area / building-wide). There is no units-create UI, so in practice new projects are building-wide unless a units row already exists.
The client is not stored on the project. Reach it via projects.building_id → tenant_buildings (same tenant) → client_id.
There is no project_services table. Services are derived live from linked orders and their decisions (buildDecidedFormBundles → servicesForTracks in domain/src/field/serviceRequirements.ts).
Runtime layers (mandatory seams)
Browser (React + Vite)
src/pages, src/components — UI only
src/data — the only `.from()` / `.rpc()` / `functions.invoke()` outside auth
src/auth — supabase.auth.* **and** a handful of identity `.from()` calls (profiles, grants, client_users)
│
▼
PostgREST + Storage + Auth — RLS is the enforcement layer
│
├── edge functions — I/O + authorize(); call domain, write rows
└── domain/ — pure TypeScript, zero I/O, @complied/domain
There is no NestJS / separate API in v1 (D15). The browser talks to Supabase. Domain logic (rulebook, feed normalization, XRF determination, branding merge, derived services) is imported by both the web app and Deno edge functions from domain/src.
When the first external tenant onboards, the intended move is: reimplement src/data/, move domain/ server-side, point the client at a new base URL.
How work actually moves: Decide → Work → File → Money
Project detail (/projects/:id) is a four-tab workspace. All four tabs are always reachable, regardless of projects.phase. That is Rung 6: nothing in the app may read phase to decide what a user can do.
| Tab | Job | Primary data |
|---|---|---|
| Decide | Pick a resolution path per linked HPD order (cure / contest / postpone / dismiss, plus contest ground). | project_order_decisions (append-only; a change of mind inserts a new row) |
| Work | Derived tracks, proposal, per-visit scheduling, open field job. | inspections + inspection_events; services from servicesForTracks |
| File | Required-document checklist, generate HPD forms, affidavits, filing package row. | documents + document_orders; filing_packages |
| Money | Invoices and vendor payments. | invoices, vendor_payments, rate_cards |
A typical violation-origin path:
- PM tracks the building (
tenant_buildings) and creates a project at/projects/new, linking one or morepublic_events. - Decide records a path per order. Until a decision exists,
buildDecidedFormBundlesuses the cheapest-cure guess insidebuildHpdFormBundles. - Work derives field-visit tasks (
buildDecidedFormBundles→servicesForTracks; undecided orders fall back to the cheapest-cure guess). Staff schedule per inspection, not on the project row. "Open field job" goes to/field/:inspectionId. - File fills document slots (reuse-policy aware). Assembling a filing package ensures decided HPD docs exist, then writes a
filing_packagesrow plus join rows — it does not produce a merged PDF today. - Money issues an invoice (silent $0 if
rate_cardsis empty — see LIFECYCLE_ASSESSMENT).
Origins. origin is immutable: violation | obligation | occupant_request. Creating from a violation writes project_event_links at create time. Creating from obligation is a label only — there is no obligations engine. After create, there is no link-events UI on project detail. Obligation / occupant-request projects can still open the Decide tab; it is empty until events are linked.
Phase is a label (Rung 6)
The six names still exist as an ops dropdown on the project header:
intake → scheduling → field → docs_qa → billing → closed
Side-states blocked / on_hold / cancelled are orthogonal (setProjectSideState) and can be set or cleared without touching phase.
Nothing auto-advances. Nothing is gated on phase. setProjectPhase updates projects.phase directly from the browser (UI gated on the advance_projects permission; RLS only checks tenant scope). History is captured by the projects_log_phase_transition trigger into project_phase_transitions.
Deleted and must not be resurrected as a phase engine:
domain/src/phases/gates.tsand the*/gate.tsmodulessrc/data/projectTransitions.tssupabase/functions/advance-project-status/PhaseStepper.tsx
Candidate action preconditions (close the project, complete an inspection) that do not read phase live in GATING.md and docs/history/DESIGN-C.md. None of those candidates have shipped as enforcement.
closed changes a badge tone. Every edit control stays live.
Services (code catalog, derived at runtime)
domain/src/field/serviceDefinitions.ts: xrf, dust_wipe, paint_chip, abatement. inspections.service_type is unconstrained text on purpose so this file stays the source of the allowed set.
Staff do not declare services. The Work tab, proposal PDF, and document checklist all consume the same derivation from linked orders + decisions.
Scheduling lives on the visit: inspections.assigned_inspector_id, scheduled_date, access_status. Project-level singleton scheduling columns (assigned_inspector_id, scheduled_date, access_arranged) and project_type were dropped in the Work tab rework.
What lives where in the repo
| Path | Role |
|---|---|
src/ | Staff app + client portal |
domain/ | Pure rules: ingestion, field/XRF, HPD rulebook, branding, notifications, derived services |
supabase/migrations/ | Schema + RLS + RPCs (has_permission, enqueue_email, allocate_invoice_number, …) |
supabase/functions/ | Orchestrator, PDF generators, notify, email workers. No phase-advance function. |
db-tests/ | Cross-tenant leakage + ingestion regression against local supabase start |
archive/ | Pre-rebuild app. Archaeology. Do not treat as spec. |
Staff routes that exist
| Route | What |
|---|---|
/buildings, /buildings/:id | Tracked buildings + events |
/clients, /clients/:id | Clients |
/projects, /projects/new, /projects/:id | List, create, Decide/Work/File/Money |
/field, /field/:inspectionId | Field-jobs queue + inspection wizard |
/map | Complied Map (citywide, scoped filters) |
/violations | Violations explorer |
/settings | Licenses, rate cards, branding (avatar menu, not the sidebar) |
Legacy alias: /projects/:projectId/inspections/:inspectionId still opens the wizard (projectId ignored).
Sidebar also shows Dashboard, Tasks, Sales, Deadlines, Reports, Billing, Communications, Compliance/Lab, Notifications, Admin, Knowledge — those entries are dead stubs (no to). Portal: /portal/dashboard, /portal/buildings, /portal/buildings/:id, /portal/violations, /portal/projects/:id, /portal/documents, /portal/invoices. There is no portal projects list route. Public: / (marketing + Mapbox hero).
Three data classes (RLS)
| Class | Policy shape | Examples |
|---|---|---|
| Public reference | Authenticated read, USING (true) allowed only here. Writes are service-role syncs. | buildings, units, public_events, XRF catalogs, compliance_programs |
| Tenant-owned | tenant_id = current_tenant_id(), plus a narrow collaborator exception on field/project tables | clients, projects, inspections, documents, invoices |
| Platform / locked | Operators, or no authenticated grant at all | tenants, platform_operators, impersonation_sessions, email_outbox |
Financials (invoices, rate_cards, vendor_payments, documents, licenses, filing_packages) have no collaborator RLS branch. Any active collaborator grant can see the project. Field-table writes are role-scoped: field_execution (inspections, XRF, floor plans, inspection_events); plus lab_coordination on samples/CoC; plus abatement on abatement_components. clearance_sampling is catalog-only (no RLS array). Money stays tenant-only.
client_user is a separate JWT path: no profiles row. Portal RLS is "this client's buildings/projects/documents, and non-draft invoices."
Collaborator invite roles (domain/src/collaboration/collaboratorRoles.ts): field_execution, abatement, clearance_sampling, lab_coordination. Independence warnings (28 RCNY abate vs clearance) are warn-only. The invite picker uses collaboration_tenant_directory() so staff no longer paste a tenant UUID.