Application map / Identity & authorization

Identity & Authorization

The org/property/role model everything else depends on: who exists, who can see what, and who can change whom.

Enforcement reality verified 2026-09-07: the design calls for the visibility predicate to be enforced twice — application layer and database row-level security. Today RLS is inert: policies are installed in production Postgres, but the app connects as the table owner (which bypasses RLS), so the application-layer can_see_property check is the sole live gate. Turning enforcement on is a config switch documented in the RLS activation runbook.

The model

CLERK — IDENTITY & TENANCY HOA org President · Treasurer · Board flat peer titles, no rank owns the property — its identity CMC org Executive > Portfolio mgr > Property mgr ranked 3 > 2 > 1 manages via consent-based link unset = unmanaged APP DATABASE property — the atomic entity hoa_org always set cmc_org set when managed portfolio_mgr assignment property_mgr assignment archived hides everywhere + financial attributes roster (computed at read) HOA members + assigned property mgr owns manages
Clerk owns who people are; the database owns the property and the cross-org links. Companies and users appear in our tables only as opaque Clerk id strings.

Exactly two organization types, held in Clerk: an HOA (one self-managed community) and a Community Management Company (CMC) that manages many communities on behalf of their boards. Every user belongs to exactly one organization.

The property is the atomic entity, and its HOA ownership is its identity. A property is always owned by exactly one HOA; a CMC never owns a property outright — it gains one only through a consent-based management relationship with an HOA already on the platform. The property row carries the links and assignments in the figure, the financial attributes, and a lifecycle marker (archived properties disappear from all visibility).

The roster crosses organizations. A property's roster is its HOA members plus the one property manager assigned to it (who belongs to a CMC) — everyone on it sees the same table. It is computed at read time, never stored: Clerk organizations are flat and a roster deliberately spans two of them.

Two independent role dimensions

  1. Membership role — a person's title, per org type. HOA: President, Treasurer, Board (flat peer titles, no rank). CMC: Executive > Portfolio Manager > Property Manager (ranked 3 > 2 > 1).
  2. Org Admin — an administrative capability layered on top of the role. The first person onboarded for an org is an Org Admin by default. Whether someone is an Org Admin is visible only inside their own organization — an HOA member sees the property manager's name and role but never their CMC-internal admin status (the API omits the field entirely for cross-org viewers, never sends false).

Invariants

Who owns what

Two systems, one job each. Clerk owns identity, tenancy, and the admin capability; the application database owns the property and the cross-org links. No mirror tables, no sync, no webhooks; companies and users appear in our database only as opaque Clerk id strings. Clerk mints user/org/membership ids; we mint property and link ids. Org creation is backend-owned and — because it spans Clerk plus the database and cannot be one transaction — idempotent and resumable: a retry detects the existing org and resumes the missing step, and a first-request reconciliation routes an org-without-property caller into "finish setup".

Claim shapes (the canonical contract)

WhereKeysNotes
Membership publicMetadatarole, isOrgAdminSource of truth. Roles: hoa_president / hoa_treasurer / hoa_board / cm_executive / portfolio_manager / property_manager.
User publicMetadatamembershipRole, isOrgAdmin, platformAdminMirror of the membership values (users are single-org, and only user metadata rides the JWT), plus the platform-admin flag — never self-settable, set only out-of-band.
Org publicMetadatatype"hoa" or "cmc". Org type is data, not branching: it selects the role vocabulary and authority rule from one config map.
JWT (custom template)org_type, membership_role, isOrgAdmin, platformAdminSteady state is zero per-request Clerk calls: the backend reads verified claims.

Visibility — one predicate

The whole pyramid is a single predicate, canSee(user, property). Everything a caller sees is the set of properties where it is true. Higher roles see nothing new — just more of the same: a property manager or HOA member sees one roster, a portfolio manager a list of them, a CMC executive the whole company's grouped by portfolio manager, the platform admin everything.

Platform admin every property, grouped by company CMC executive all company properties, grouped by portfolio manager Portfolio manager their assigned portfolio Property manager · HOA president / treasurer / board one property, one roster
The pyramid is aggregation: each scope contains the ones below it. How many properties pass canSee is the "My Property" vs "My Properties" distinction.

A property is visible iff it is not archived and one of:

ClauseReads as
caller is platform adminsees all, grouped by company
caller's org is the property's HOA orgHOA roles: their single roster
caller is a CMC executive whose org is the property's CMCall company properties, grouped by portfolio manager
caller is the assigned portfolio managertheir portfolio
caller is the assigned property managertheir one property

There is no per-role query; presentation (single / list / grouped) is frontend bucketing of the same result set. Every read or write of a property's financial data names its property explicitly and is authorized through this same predicate — never inferred from the caller's active org, because the visible set crosses org boundaries. The one exception: an organization editing its own profile (name, address, balance, fiscal-year start) resolves from the caller's org by definition.

Defense in depth (design vs. today): the predicate is meant to be enforced twice — in application code on every read, and by database row-level security applying the identical rule to claims-scoped sessions, kept honest by a parity test asserting both forms agree over shared fixtures. The policies and parity test exist; enforcement is inert until the app connects as the restricted role (runbook).

Authorization — the Org Admin rule

One server-side check governs every role/membership mutation. Actor A may mutate target B iff A is a platform admin, or: A is an Org Admin in the same organization as B, A ≠ B, and — for a CMC — A strictly outranks B, with a role change additionally requiring the new role to rank strictly below A ("promote at most one rank below your own"). Never demote the last org admin. Rank applies to CMC roles only; in an HOA any Org Admin manages any member.

Lifecycle: linking, offboarding, onboarding

LINKING (CMC-INITIATED, IN-APP ONLY) CMC admin enters a board member's email — no ids pasted Backend resolves email → property, raises a request HOA admin accepts from the bell — no email is sent Atomic accept link set only if still unset; rival offers deleted with it OFFBOARDING (CMC-SIDE, PYRAMID APPROVAL) Property manager initiates offboarding Portfolio manager approves / declines; fallback approver: exec / Org Admin Link + managers clear property leaves the whole CMC's visible set HOA keeps all data and is notified
Both flows are consent-based: no link forms or breaks without both parties.

Linking

Three ways a property becomes CMC-managed: the CMC creates the property directly (with its CMC link set — used when the CMC also provisions the board's org); an HOA admin offers management to a CMC; or a CMC requests an HOA. The surfaced product is the CMC-initiated flow in the figure. Creating an offer requires Org Admin on the initiating side; accepting requires Org Admin on the opposite side; an offer exists only for an unmanaged property, and an accept that finds the link already set is a no-op meaning "already managed" — rejected.

Offboarding

CMC-side with pyramid approval, as in the figure. HOA-initiated linking and offboarding exist as latent capability but are not surfaced.

Onboarding

Dashboard-first: the profile modal provisions the org the instant persona and role are picked (HOA also gets a placeholder property), so every page loads immediately behind a "needs setup" state; there is no mandatory wizard. The notification bell is the union of derived pending actionables (requests to accept, offboardings to approve) and stored already-happened events — never mocked.

UX principles that follow from the model