Organizing teams with sites and roles
Once you outgrow a single warehouse or depot, raw verifications aren't enough — you need to know which site they belong to, who can act on them, and how to keep one site manager from seeing another site's data. VerifyAI ships a small but opinionated model for this: sites are the unit of physical scoping, member-site assignments wire users to sites, and a four-tier role matrix controls what each user can do.
By the end of this guide you'll know how to:
- Create sites and tag verifications with
site_id. - Assign members to sites with the right role.
- Reason about Row-Level Security (RLS) scoping.
- Migrate an existing org-wide setup without breaking anything.
1. The sites schema
Sites live in verify_ai_sites (migration 20260530_verify_ai_sites.sql)
and belong to a customer:
| Column | Type | Notes |
| ---------------- | ----------- | -------------------------------------------------- |
| id | UUID | Primary key. |
| customer_id | UUID | FK → billing_customers(id), ON DELETE CASCADE. |
| name | TEXT | Human label ("LAX Depot", "Atlanta DC"). |
| external_id | TEXT | Stable ID you control. UNIQUE (customer_id, external_id). |
| region | TEXT | Optional region tag for grouping. |
| timezone | TEXT | IANA tz, defaults to UTC. |
| metadata | JSONB | Free-form, defaults to {}. |
| archived_at | TIMESTAMPTZ | Soft-delete; archived sites hide from UI. |
| created_at | TIMESTAMPTZ | Defaults to now(). |
Member assignments live in verify_ai_member_site_assignments
(composite PK on (member_id, site_id), role constrained to
site_manager or site_viewer) and join customer_members to
verify_ai_sites. The same member can be assigned to many sites;
org-level roles (see below) bypass the join entirely and see
everything.
Verifications carry an optional site_id column. NULL means
org-wide — these rows are visible to org owners / admins but not to
site-scoped users. This nullability is what makes the rollout
backward-compatible: pre-existing rows stay visible to the people who
were already seeing them.
2. The role matrix
There are four roles. Permissions cascade — a role can do everything the role below it can.
| Role | Sees | Can do |
| -------------- | --------------------------- | ----------------------------------------------------------------------- |
| org_owner | Everything in the org | Members, sites, policies, integrations, audit log, exports, all data. |
| org_admin | Everything in the org | Same capability set as org_owner in lib/auth/permissions.ts. |
| site_manager | Only their assigned sites | Triage / resolve exceptions and request BI exports within those sites. |
| site_viewer | Only their assigned sites | Read-only — view verifications and reports for assigned sites. |
Capabilities are resolved by getMemberCapabilities() (see
lib/auth/permissions.ts). The full matrix:
| Capability | org_owner | org_admin | site_manager | site_viewer |
| ------------------- | ----------- | ----------- | -------------- | ------------- |
| editPolicies | yes | yes | no | no |
| manageMembers | yes | yes | no | no |
| manageSites | yes | yes | no | no |
| manageIntegrations| yes | yes | no | no |
| viewAuditLog | yes | yes | no | no |
| viewAllSites | yes | yes | no | no |
| resolveExceptions | yes | yes | yes | no |
| exportBI | yes | yes | yes | no |
| viewVerifications | yes | yes | scoped | scoped |
Server actions call this helper before mutating anything, and the
dashboard hides controls a member can't exercise. Note that
site_manager can resolve exceptions and run BI exports but cannot
manage members, sites, or integrations.
3. Tagging a verification with a site
Site resolution happens server-side in lib/verify-ai/service.ts
(marked CLUSTER-3-DISPATCH) and reads two fields from the request
metadata, in priority order:
metadata.site_id— direct UUID. Wins if both are present. The site must belong to the calling customer; otherwise it's ignored.metadata.site_external_id— looked up againstverify_ai_sites.external_idfor the customer.
If neither resolves, the verification is created with site_id = NULL
(org-wide). Site resolution is best-effort and never blocks a
verification — a missing or unknown site silently falls back to
NULL.
# external_id is the easier path — you control the string.
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
-H "X-API-Key: vai_your_api_key" \
-F "image=@end_of_ride.jpg" \
-F "policy=pol_forest1" \
-F 'metadata={"site_external_id":"LAX-01"}'// React Native — site_external_id is the recommended path.
await verify({
image: base64,
policy: "pol_forest1",
metadata: {
site_external_id: "LAX-01",
trip_id: tripId,
},
});4. Scoping (RLS + app layer)
Site-level access control is enforced primarily in application
code, not RLS. The dashboard runs as the service role (admin client)
and consults getMemberCapabilities() to decide what each member can
see. Specifically:
verify_ai_sitesandverify_ai_member_site_assignmentsenable RLS with a service-role policy for writes.verify_ai_sitesadds a read policy that lets any authenticated member of the customer org see the site list — finer scoping happens in the server actions (seelistSites/listExceptionsinapp/actions/operations.ts, which filter bycaps.siteIdswhen the member lacksviewAllSites).- org_owner / org_admin —
caps.can.viewAllSites === true, so queries skip thesite_idfilter and see everything in the org. - site_manager / site_viewer — queries are restricted to
caps.siteIds. Rows withsite_id = NULL(legacy org-wide verifications) are not returned to these roles, since the filter is.in('site_id', siteIds)rather than including NULL.
The service role (used by background jobs and logAuditEvent) bypasses
RLS as usual.
5. Migrating an existing org
Existing customers have a single implicit "site" — the whole org. To roll out without breaking anything:
- Add sites via Dashboard → Operations → Sites or
createSite({ name, externalId, region, timezone, metadata })inapp/actions/operations.ts. Onlynameis required. - Backfill
site_idon existing verifications you want scoped (a one-lineUPDATEkeyed onmetadata->>'site_external_id'is the typical move). - Assign members. Until you assign anyone as
site_manager/site_viewer, everyone keeps their existing org-level access and nothing changes.
What's next
- Triaging low-confidence verifications — site managers spend most of their time here.
- Audit log and SOC 2 readiness — every site / member change is recorded.
- Exporting verification data for BI tools — scope an export to a single site.