Skip to main content

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:

  1. Ingest public NYC agency data onto buildings.
  2. Turn a trigger into a project (violation, obligation, or occupant request).
  3. Decide a resolution path per linked HPD order, Work the derived field tracks, File the paperwork, Money the invoice.
  4. 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)

WordMeaning here
TenantWhite-label customer org (SecureEnv, Abated). Never a resident.
OccupantPerson living in a unit. NYC law calls this a "tenant"; we do not.
ClientA property owner / managing agent of a tenant. Logs into /portal/*.
Platform operatorComplied staff above all tenants. Table exists; no HQ UI shipped.
Building / unitCitywide public registry (BIN / apartment). Not owned by a tenant.
Tenant building"We service this BIN, for this client." The tracking layer.
Public eventOne agency record (violation, complaint, hearing, lien, 311, …).
ProjectOne unit of billable work. Always has a program and an origin.
InspectionOne scheduled field visit on a project (a project can have many).
DecisionThe chosen resolution path for one linked HPD order (project_order_decisions).
TrackA 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_idtenant_buildings (same tenant) → client_id.

There is no project_services table. Services are derived live from linked orders and their decisions (buildDecidedFormBundlesservicesForTracks 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.

TabJobPrimary data
DecidePick 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)
WorkDerived tracks, proposal, per-visit scheduling, open field job.inspections + inspection_events; services from servicesForTracks
FileRequired-document checklist, generate HPD forms, affidavits, filing package row.documents + document_orders; filing_packages
MoneyInvoices and vendor payments.invoices, vendor_payments, rate_cards

A typical violation-origin path:

  1. PM tracks the building (tenant_buildings) and creates a project at /projects/new, linking one or more public_events.
  2. Decide records a path per order. Until a decision exists, buildDecidedFormBundles uses the cheapest-cure guess inside buildHpdFormBundles.
  3. Work derives field-visit tasks (buildDecidedFormBundlesservicesForTracks; 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.
  4. File fills document slots (reuse-policy aware). Assembling a filing package ensures decided HPD docs exist, then writes a filing_packages row plus join rows — it does not produce a merged PDF today.
  5. Money issues an invoice (silent $0 if rate_cards is 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.ts and the */gate.ts modules
  • src/data/projectTransitions.ts
  • supabase/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

PathRole
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

RouteWhat
/buildings, /buildings/:idTracked buildings + events
/clients, /clients/:idClients
/projects, /projects/new, /projects/:idList, create, Decide/Work/File/Money
/field, /field/:inspectionIdField-jobs queue + inspection wizard
/mapComplied Map (citywide, scoped filters)
/violationsViolations explorer
/settingsLicenses, 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)

ClassPolicy shapeExamples
Public referenceAuthenticated read, USING (true) allowed only here. Writes are service-role syncs.buildings, units, public_events, XRF catalogs, compliance_programs
Tenant-ownedtenant_id = current_tenant_id(), plus a narrow collaborator exception on field/project tablesclients, projects, inspections, documents, invoices
Platform / lockedOperators, or no authenticated grant at alltenants, 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.