Core concepts
The mental model
Five ideas explain almost everything about how polimorf is put together: organizations, the two sides you work from, the account types behind them, the isolation that walls each organization off, and how a request knows which organization it belongs to.
The organization is the unit
An organization is one business you run software for — one client, one company, one brand. It is the boundary everything else lives inside: its own data models, records, automatic tasks, dashboards, members, roles, and settings. Almost every URL and API call in polimorf is scoped to exactly one organization.
Each organization has a stable UUID, a human slug (immutable once set, because routing and stored references depend on it), a display name, a plan tier, and a default currency. You can run as many organizations as you like from a single account — one per client is the common shape.
Why "organization" and not "tenant"
Under the hood this is multi-tenant infrastructure, but you never think in tenants. You think in organizations — the actual businesses you are building for.Two sides: build console and client workspace
Every organization is approached from one of two sides, and which one you see is decided by your account type, not by a setting you toggle.
- The build console is where the team that builds the software works — agency or operator. From here you create organizations, model data, install setups, wire up automatic tasks, and manage members. A developer account lands in the launchpad at
/app/dev, where you pick or create an organization. - The client workspace is the clean, role-scoped place you hand to the team that runs the day. They see only the records, reports, and navigation their role allows, and only their one organization — never the build console, never anyone else's data.
You build once on the console side; the client runs on it from the workspace side. The same organization, two doors.
Account types and personas
The two sides map to two account types, carried both on the user and on each organization membership:
| Account type | Lands in | Sees |
|---|---|---|
| developer | /app/dev | Every organization they build, plus the full build console |
| client | /app/select-organization | One organization, scoped to the role they were granted |
Account type is set per membership: the same person can be a developer on the organizations they build and a client on one they were invited to. When no membership-level account type applies, the user's default account type is used. The frontend derives a single isDeveloper persona from the active organization's membership, which decides whether you see the build console or the client workspace.
Access to an organization is invite-only — there are no public sign-ups into one. A developer sends a role-scoped invitation; the recipient joins as a client. So a client can never appear in an organization without someone adding them.
Platform admin is separate
There is also anis_platform_admin flag for operators of the platform itself. It is not an organization role and not something a client account ever holds. One database per organization
Organizations are not separated by a WHERE organization_id = … filter that a forgotten clause could leak through. They are physically isolated. polimorf runs a control plane — one database that holds the registry of organizations, users, memberships, and routing — and, for each organization, an isolated schema holding that organization's own data.
The default isolation mode is schema: every organization gets its own Postgres schema, and an organization can be promoted to its own dedicated database when it warrants one. A short-lived Redis cache maps an organization to its cluster, database, and schema so this lookup stays off the hot path.
When a request is scoped to an organization, polimorf opens a transaction and pins it to that organization's schema for the life of the transaction. No query has to name the organization — the search path already points at the right data:
-- Every org-scoped request opens a transaction pinned to that org's schema.
-- Nothing in the query needs to mention the organization — the search_path does.
SET LOCAL search_path TO "org_9a4e…", public;
SELECT * FROM orders; -- reads org_9a4e…'s orders, and only thoseBecause the boundary is the schema, not a column, there is no query you can write — and no bug you can introduce — that reads across organizations by accident.
How the active organization is resolved
On every request, polimorf has to decide which organization the request belongs to before it touches any data. It tries a short, ordered list of strategies and stops at the first that answers:
| Strategy | Where the organization comes from |
|---|---|
| Access token | The oid claim in a bearer JWT — the usual path for the console and workspace |
| API key | The organization the pk_/sk_ key belongs to |
| Webhook path | The organization slug embedded in an inbound webhook URL |
| Hostname | A custom or subdomain host mapped to an organization |
For an authenticated user, the source of truth is the oid (organization id) claim inside the signed access token. The token names exactly one organization, and that is the only organization the request can reach:
{
"sub": "f1c0…", // the user
"oid": "9a4e…", // the active organization — the one this token can reach
"roles": ["admin"], // their roles in that organization
"scopes": [],
"at": "developer", // account type: developer or client
"is_platform_admin": false,
"typ": "access"
} If a request presents a credential but it cannot be resolved, polimorf fails closed rather than falling back to host or header routing — a guard against reaching another organization with a bad token. Switching organizations is therefore not a UI flag: it mints a fresh token with a new oid.
The frontend tracks the token, not the other way round
In the dashboard, the active organization is read straight from the token'soid. If the two ever diverged, every org-scoped request would be rejected — so the UI derives its active organization from the token and never the reverse. Org-bound surfaces are guarded by default: without an active organization, a developer is sent to /app/dev and everyone else to /app/select-organization. A word on roles
Account type decides which side you see; roles decide what you can do once inside an organization. The built-in roles are admin, editor, and viewer — admin for configuration changes, editor for content writes, viewer for read-only. Roles travel in the same access token as the active organization, so they are always evaluated against the one organization the request is scoped to.
Roles are only the coarse layer. Finer field- and row-level rules sit on top of them. The full picture — how permissions evaluate, row-level access, and API-key scopes — lives in Security & access.
Where to next
With the model in hand, three good directions:
- Schema designer — model the data that lives inside an organization.
- Blueprints — install a ready-made setup for a kind of business in one transaction.
- API reference — the auth flow, organization scoping, and dynamic endpoints in detail.