Tiers, roles, the two drivers, tokens, and the pagination every list surface shares.
Every read or write has an environment, server or client, and a tier:
| Tier | Who | Cached |
|---|---|---|
| public | anyone | yes, per tenant |
| protected | any signed-in user | yes, per tenant |
| private | the owner and admins | no |
The driver and role follow from those two. Writes always go through the Data API as the signed-in user, under row-level security. Server reads of public and protected data use a credentialed read-only role over the serverless driver, so they can be cached. Private reads, and every client read, use the Data API with the caller's token.
| Role | Reaches |
|---|---|
authenticated | A signed-in user through the Data API, row-level scoped. |
anonymous | A signed-out visitor through the Data API, public tables only. |
reader | Cached server reads of public and protected tables. No write. |
rpc_writer | Sessionless server-to-server writes, through definer functions only. No table grants. |
| owner | Operations only: DDL and seeds, bypassing row-level security. |
The Data API verifies the caller's token and switches to authenticated or anonymous; the app never chooses. The two credentialed roles carry their password in the connection string and so are server-only, and neither is the table owner nor bypasses row-level security.
Reads follow the tier's role list. Writes are admin-only on public and protected tables and owner-or-admin on private ones. A server-only tier holds the one secret that lives in a tenant database, its storage keys; it is readable by the read-only role alone and revoked from every Data API role.
Every view runs as its caller, so a view can never widen what its base tables allow. Order child rows scope through their parent order's policy rather than repeating it. Territory, the org tree a field agent covers, is a data model layered on top as additive read policies, not a role.
The server gets a token for free from the session cookie, and reuses a minted token across requests until shortly before it expires, since the auth instance rate-limits minting per project. The client mints its own through the app's token route, caches it at module scope keyed on expiry, dedupes in-flight mints so parallel queries share one, and re-mints on a 401. Signing in or out clears the cache, or a freshly signed-in user keeps reading as anonymous until the old token expires.
A data client takes no tenant argument. It resolves the tenant from the request, so a caller cannot reach any tenant but the one the request verified. The one exception is the cached reader, which runs inside a cache scope where request context is unavailable; there the slug is the cache key, and it stays internal to the cached function.
Both use the same Data API and the same policies. Web uses the whole matrix. Mobile uses only the signed-in Data API cell: there is no server render, so reads come from a persisted query cache and writes from a durable outbox that replays the same direct writes, with device-minted ids making replay idempotent.
Pick the loading strategy by how a table grows, not its size today. A bounded reference table is fetched whole and filtered in the browser. An unbounded table is read with a keyset cursor, never an offset: a composite (sort…, id) cursor, a matching composite index, and forward-sequential blocks. The tie-breaker is always the id.
One cursor read serves every list surface. A module declares which columns a feed may sort and filter on, and the grid, the list and the export all run through it. Date filters bracket the viewer's local day, computed wherever the relation is known from a time zone the browser supplies, so the feed, the in-memory grid and the export agree on which day a row falls in. A zoneless date column is derived from the schema and compared as the day itself.