Skip to Content
DevelopersConceptsWorkspace / org boundary

Workspace / org boundary

Under the 2026-05-26 hierarchy revision, Workspace = brand-tenant and Location = outlet. The “workspace-vs-org” distinction has collapsed for in-tenant writes — a workspace operator on a workspace page IS the brand-tier admin for that brand. The scope picker is the chip-strip’s ?location= URL state, not a separate route.

This page is the developer-side reference for how the scope boundary is expressed end-to-end. The operator-facing description is at workspace vs organization.

The rule

The chip-strip is the scope picker. Brand-tier writes happen from the same workspace page as outlet writes — but they omit X-Workspace-Id on the wire.

In effect:

  • Chip = “All outlets” (or a single-outlet brand) → brand-tier write
  • Chip = a specific outlet → outlet-tier override
  • The backend separator stays: brand-tier writes omit X-Workspace-Id; outlet writes carry both X-Workspace-Id and a location_id body field
  • Cross-brand mismatches (a request carrying X-Workspace-Id while declaring scope_tier='org') are still rejected with 403 — that combination is malformed and indicates a UI bug

The pre-P10.1 /dashboard/defaults route is deleted. next.config.ts 301-redirects /dashboard/defaults/*/dashboard/portfolio for stale bookmarks. Brand-tier authoring lives inside each product’s workspace page.

Enforcement points

The boundary is enforced at three layers.

1. Backend (API guard)

Every CRUD endpoint for a Shape A or Shape B substrate (see inheritance patterns below) checks the cross-brand mismatch:

# pattern def create_thing(payload, workspace_id, ...): if payload.scope_tier == 'org' and workspace_id: raise HTTPException(403, "Cannot write org-tier with X-Workspace-Id header") ...

PATCH / UPDATE on an existing org-tier row from a workspace context returns 403.

The agency-mode cross-brand boundary (operators jumping across brands via the Clerk org switcher cannot mix two brands’ data on one page) is enforced platform-wide — the /dashboard/portfolio route is the only legitimate cross-brand surface.

2. Frontend (UI affordance)

The dashboard surfaces brand-tier and outlet-tier authoring on the same product page. The chip-strip at the top of every workspace page selects scope:

  • Chip = “All outlets” (or single-outlet brand) — brand-tier authoring cards mount, edits write to the brand row
  • Chip = a specific outlet — outlet-tier override editor mounts on the same page, edits write to the outlet row
  • The two are mutually exclusive on a single page render, so operators never see ambiguous “which tier am I editing?” state

For multi-row scoped substrates (Shape B, see below), the create/edit dialog includes a scope picker on the workspace surface but hides it on the brand surface (where only brand tier exists).

Inheritance patterns

Settings come in two shapes; the boundary applies differently to each.

Shape A — single record per tier (cascading value)

Substrate: one row per (org_id, workspace_id, property_id) triplet. Each tier optionally overrides selected fields. Reads resolve to the most-specific row.

Example: tenant_review_settings — every org/workspace/property has at most one row of nullable knobs.

UX: Brand-tier writes happen on each product’s workspace page when the chip-strip has no outlet selected. Outlet-tier (property) writes happen on the per-product Settings tab (e.g. /feedback → Settings) — and a single legacy InheritancePicker on the feedback settings tab points at the product’s workspace page for brand-tier edits.

Read resolution picks the most-specific row at the cell-plane layer and falls back to the brand defaults stored on the organization record if no row exists at any tier.

Shape B — multi-row scoped table

Substrate: arbitrarily many rows; each row owns its own (tenant_id, workspace_id, property_id) triplet. A workspace can have N rows; each row belongs to exactly one tier.

Examples: loyalty_earning_rules, external_review_platform_configs.

UX: NOT InheritancePicker. The right pattern is:

  • Scope badge on each row (Org / Workspace / Property)
  • Scope filter above the table (All tiers / Org defaults / This workspace / Specific property)
  • Scope selector in the create / edit dialog (“Apply this rule at: …”)

The brand-tier authoring surface for Shape B substrates lives on the product’s own workspace page as a table-style card. The card reuses the workspace-surface create/edit dialog but hides the scope picker (only brand tier exists here) and writes to the brand row without an X-Workspace-Id header.

Decision rule

If the setting is…Use…
”Set once, override per scope”Shape A → InheritancePicker
”Multiple records, each scoped”Shape B → table with scope badge + filter + selector

Mistaking one for the other is the most common drift source. Decide BEFORE designing UI.

Channels are workspace-scoped — never org-tier

Outbound channels (WhatsApp, SMS, email, RCS) cannot have org-tier defaults that cascade. The setup that makes a channel work is workspace-bound by the upstream provider:

  • WhatsApp: each workspace registers its own WABA number with Meta — there’s no “shared org WhatsApp account” Meta will allow
  • SMS: DLT sender ID is registered per-business with TRAI — every workspace must have its own
  • Email: DKIM / SPF / domain verification is bound to a single sender domain per workspace
  • RCS: per-workspace carrier enablement

Therefore: there is no brand-tier channels card. Channel configuration lives only at /dashboard/{workspaceSlug}/settings/channels. Don’t reintroduce a brand-tier channels surface — it would mislead operators into thinking they could cascade something the provider doesn’t allow.

(Property-tier channels = in_app + push only — those ARE per-property because each property = one SDK install with its own VAPID / FCM / APNs keys.)

Historical scope-fix (May 2026)

A platform-wide sweep corrected two pre-existing CRUD bugs on multi-row scoped (Shape B) substrates: CREATE wasn’t stamping the workspace owner, and LIST was hiding brand-tier rows from workspace operators. After the fix, every historical rule shows up as “Brand default” in the new scope-badge column. No data migration is possible — we can’t infer the operator’s original intent. Tenants who relied on the old behaviour as if rules were workspace-scoped need to manually deactivate and re-create at the correct tier.

What’s next