Skip to content

Permissions & Enforcement

layoutMode, Retail Studio's current locked-versus-editable mechanism, is confirmed by its own code comments to be an affordance gate, not authorisation — it hides UI, and nothing stops a direct PATCH. That was fine while only trusted admins could reach the top tier; it stops being fine the moment external users get real composing power. This page owns the tier scale, what it's built on, and the one piece of genuinely new plumbing it needs.

The tier scale

Composer isn't a separate tool sitting alongside the Campaign Editor. Retail Studio is converging on one underlying system, and Composer is that system: one engine that applies Changes, used by every editing surface. What varies isn't the tool; it's the edit permission tier a user holds in an organisation, which governs which Change types they're permitted to emit against any fork in that organisation.

This is a graduated scale, not a single dividing line:

Rung May emit May not
Campaign permission — the bottom rung setInstanceField on exposed fields, commit of the campaign fork it holds, and the fork-level rebase of that fork onto a newer Project version Any other Change type: no Scene/Element composition, no layout, no reordering
Low Composer tier Also setInstanceLayout, setManifestBinding and setDocumentProp on a Scene or Project's own manifest and document (a Scene's own duration, its enter/exit motion), and commit of Scene and Project drafts Add, remove, or reorder Element instances; reach into any Element's internals
High Composer tier (BSR) Also addElementInstance, removeElementInstance, setInstanceOrder, rebaseInstance, and the Project-altitude Scene and Overlay equivalents — composition privilege Touch an Element's own internal primitives

Commit rights ride the rung that could author the draft in the first place: a High-tier user who adds a product to a Scene can commit that Scene version, and a campaign user who fills in field values can commit their campaign fork. The fork-level rebase follows the same principle — whoever holds a fork may rebase it onto a newer base, campaign permission included, since otherwise the bottom rung could never pick up a corrected Project (The Change Model → What sits outside Change[]). The separately grantable permissions below are for Element internals specifically, not for committing or rebasing at the altitude you are already permitted to edit.

Two grants sit outside the scale entirely. manage:brand-style is the organisation-admin capability that admits setBrandStyleField, and so governs the brand's colour and font roles and its default Overlay (Brand Scoping & Brand Style). It is off-scale for the same reason the Element permissions are: a brand's style profile isn't one Project's composition, and a user trusted to restructure a Scene isn't automatically trusted to repaint every Project for the brand. manage:retail-incident is the second, and it admits the one lifecycle operation no composing rung should carry: reverting a published version pointer (Testing, Preview & Incident Response). Reverting deliberately skips the commit and approval cycle for speed, so it cannot ride the rung that would otherwise have to approve the change it undoes; it is an incident-response act, held by whoever is on the hook for one.

Decided, per direct instruction: one unified tier scale, not two separate permission systems

Composer permission and campaign permission converge as one scale. Campaign permission isn't a categorically different boundary that happens to sit near Composer's tiers; it's the bottom rung of the same scale — "can only fill in already-exposed field values" being the lowest tier a user can hold. The Template/Campaign split relocates onto this one scale rather than surviving as its own boundary. A campaign is a placement fork of a Project version (The Change Model → Two words, defined once), not a copy of it.

The pattern repeats at every altitude, each level exposing a configurable surface to the level above without letting that level reach inside: an Element author builds an Element, exposing fields to Composer; a Composer user composes a Scene or Project, exposing fields to campaign users, but never reaches into an Element's internals; a campaign user configures one campaign's values for an already-composed Project, but never reaches into its composition.

Element authoring: three separately grantable permissions, not a rung

Element authoring — building an Element's underlying structure out of primitives — is a near-term requirement for end users, not an internal-engineers-only job. It sits beside the scale above rather than on it, as three distinct, separately grantable permissions:

  1. Creating a new Element from primitives.
  2. Editing an already-existing Element's internals — a bigger blast radius than creating one, since a shared Element may already be placed into many Scenes.
  3. Assigning which brands or organisations an Element belongs to or is shared with — an administrative capability, closer to the brand-scoping admin panel (Brand Scoping & Brand Style) than to either authoring capability.

Permissions 1 and 2 are what admit the authoring Change types (The Change Model) against an Element, and therefore what gates committing an Element version — including the version a confirmed schema migration commits (Identity, Versions & Schema Versioning), which needs no fourth permission. Permission 3 gates the sharing-list edit, which sits outside Change[] entirely. The authoring tool is grounded in extracting TemplateBuilder's existing core (Editing UI & Canvas).

How a field's editTier meets a user's rung

A manifest field carries an editTier — the four-valued existing enum, basic < intermediate < advanced < author — set by whoever exposed it, via setManifestBinding. A user's rung resolves to the highest editTier they may edit, exactly the ceiling-and-clamp pattern maxLayoutModeFor / clampLayoutMode already implements:

Rung or permission May edit fields up to
Campaign permission basic
Low Composer tier intermediate
High Composer tier advanced
Element authoring (create/edit) author

That last row is the existing system's own shape, not a new idea: author is reserved to super-admins today precisely because it means "the person who built the thing." The mapping is a proposal to confirm rather than a direct instruction, but it is the one that reuses the existing vocabulary without inventing a second scale.

The two axes don't add up. A user's ceiling on a placement fork comes from their composition rung alone; the author row applies only to Changes targeting an Element's own internals, where the Element permission is what admits the Change in the first place. So an author-tier binding has a precise meaning downstream: fixed at placement. Nobody configures it through setInstanceField at any composition rung; changing it means editing the Element, which is the point of marking a field author. An Element author who wants a field configurable picks one of the other three tiers. Otherwise holding campaign permission plus Element-create — the combination the near-term Element-authoring requirement produces — would let someone edit an author-tier field on a campaign fork that a High-tier composer is forbidden from touching, inverting the ordering on the page whose whole purpose is real authorisation.

A re-exposed field's tier is clamped upward, never downward. When a Scene exposes an Element field onward under its own manifest binding, the effective editTier is the stricter of the two: max(the Scene author's declared tier, the bound Element field's tier). Otherwise a Scene author could re-expose an author-tier price format at basic and automatic passthrough (Elements, Scenes & Projects) would hand a campaign user an edit the Element's author gated deliberately.

Elements themselves never carry an individual placement tier. Once an Element exists, anyone with composition privilege can place it; only which of its exposed properties they can then edit varies by rung (see Stickers become ordinary Elements).

Built on an untested assumption

The tier boundary this page hardens is Matthew's own working theory, deliberately kept simple and left unvalidated by design rather than incomplete by accident; every BSR example used to scope it is still the same four anecdotes from the original incident (Process & Organisation). That doesn't mean this work should wait. It means the boundary being hardened is a considered guess, and there's no near-term path to checking it against BSR's real requirements. The plan is to build a simple version now, from internal judgment, and correct it once real usage exposes what's wrong with it — not to wait for a validation signal that may not arrive.

layoutMode today: an affordance gate, not authorisation

layoutMode is documented in the code (apps/web/src/lib/retail-studio/layout-mode.ts) as an affordance gate, not authorisation — it hides UI; nothing stops a client PATCHing a locked field directly. Confirmed against the code rather than inferred: layoutMode has no server-side representation. It's a client-only Zustand store value, persisted to localStorage, clamped by a capability-derived ceiling on the client but never re-checked against that ceiling when a save happens; the one server-side use of layoutModeRank (packages/retail-studio-schema/src/validate.ts) checks a document-shape rule — a gated field must rank at or above its own gate — never who the saving user is. retail-studio.service.ts's own validation call passes no caller identity or tier at all.

The missing mechanism: composition privilege

layoutMode only governs field visibility. Checked against layout-mode.ts and the one existing restructure capability (adding a sticker layer, in CampaignEditorShell.tsx): there is no "who may restructure" concept anywhere in the code, not even a crude one to disentangle. Adding a layer today is gated by nothing but a raw layer-count cap (MAX_ADDED_LAYERS), unrelated to layoutMode entirely. So the gap is larger than splitting one conflated mechanism in two: composition privilege doesn't exist yet and needs building from scratch. Under the scale above it isn't a second axis either — a user's single rung yields both a set of permitted Change types and a field-editTier ceiling, two checks derived from one grant.

Building on what exists: the capability layer

Decided, per direct instruction: build on the existing authorisation system

Composer's tiers are built on Retail Studio's existing org-scoped capability layer, not invented alongside it.

What already exists and generalises cleanly:

  • An org-scoped capability layer: a closed enum of capability strings (packages/shared/src/capabilities.ts), assignable to a role_group a user holds per organisation (user_group_membership joined to role_group.capabilities), resolved server-side into capabilitiesByOrg[orgId] (apps/api-v1/src/lib/permissions.ts, getUserPermissions / getCapabilitiesByOrg), and checked with checkOrgCapability / hasOrgCapability (cached, 30 s TTL) or a fresh, cache-bypassing variant (checkFreshOrgCapability / hasFreshOrgCapability) for a mutation that can't tolerate a stale-cache privilege window, a demotion being the concrete case. Retail Studio already runs a real three-tier scale through this mechanism (use:retail-studio-intermediate-mode, use:retail-studio-advanced-mode, with author reserved to super-admins outside the capability system). A new Composer tier is one more capability string in the same enum, resolved by the same function, checked with the same gate.
  • A proven graduated-tier shape to copy: maxLayoutModeFor / clampLayoutMode plus useMaxLayoutMode / useEffectiveLayoutMode — "a ceiling derived from capability, a chosen value clamped to it" — real, working code, and the pattern the editTier ceiling above reuses.

Two genuine gaps: the capability system is org-scoped only (no project-level grant exists), and nothing in it checks capability below whole-request granularity (below).

Decided: Composer's tier stays org-scoped, not brand-scoped

A real, considered option: since Elements, Scenes, and Projects are brand-scoped, a permission model that also varied per brand — a user trusted to restructure Brand A's Elements but not Brand B's within the same org — was on the table. Decided against for now, to keep the permission model at the same granularity as what already exists rather than adding a new scoping dimension on day one. If a real need shows up, it's an additive change layered onto the org-scoped model, not a redesign. Whether a grant ever needs to vary per Project or per fork is a separate, still-open question below.

The concrete enforcement gate

Investigated against the actual request-handling code. The closest analog to what Composer's Change-application step will do is patchCampaign (apps/api-v1/src/retail-studio/retail-studio.controller.ts), which already applies the same four override mechanisms Change[] replaces in one request, gated by a single checkRetailSurfaceManage(req, orgId, res) call (a thin wrapper delegating to checkOrgCapabilityAny(req, RETAIL_SURFACE_MANAGE, orgId, res)). That single check is the whole finding: every mutation endpoint in this codebase gates the entire request once, never per edit inside it. A patchCampaign request containing only a trivial field edit and one containing a structural add or remove pass through the identical check.

Decided: a new checkChangeTier service-layer helper — new plumbing, not a new call site

checkChangeTier(req, orgId, changes: Change[]) walks a proposed Change[] array and rejects on the first Change the acting user may not emit, returning which Change failed rather than just pass/fail. Two lookups per Change, not one: the Change type maps to a required rung or permission (composition privilege for add / remove / move / rebaseInstance and the three Overlay types, Low tier for setInstanceLayout, the Element create/edit permissions for every authoring type that reaches inside an Element — with setManifestBinding on a Scene or Project's own manifest and setDocumentProp on a Scene or Project following the composing rung instead, and manage:brand-style for setBrandStyleField), and the target is then checked in the two cases where the type alone isn't enough. For setInstanceField, the target field's effective editTier is resolved and compared to the user's ceiling — otherwise a Low-tier user could setInstanceField an advanced-gated field and the affordance-only gap this page exists to close would be recreated field by field. For removeOverlayInstance and setOverlayCoverage, the target is checked for being a system-emitted brand-default Overlay, which no composition rung may touch whatever its type otherwise permits (Overlays).

It sits beside permissions.ts and resolves the org's granted capability set once through the fresh, cache-bypassing variant, not the 30-second-cached one: this is a mutation path where a just-demoted user must not keep composition privilege for another half minute. The type-to-rung mapping, the field-gate lookup, and the per-item loop are new work. Not an Express middleware (the check runs once per Change, not once per request) and not a Zod refinement (tier requirements are a capability lookup, not a structural rule the schema can express), but a plain service function called from wherever Change validation already runs, alongside the empty-container and brand-scope checks (The Change Model).

Because the server validates every Change against tier rules regardless of who proposed it, an AI agent later can't do anything a human at that tier couldn't (AI / Agent Forward-Compatibility). The lifecycle operations that sit outside Change[] — creating an identity, committing, reverting, the fork-level rebase, and editing a sharing list — carry their own gates, named on The Change Model; the rebase and the commit are the two that follow the rung holding the fork rather than a separate grant.

Adjacent finding, not Composer's scope

Capability checking is ad hoc everywhere, not just for layoutMode: roughly 150 call sites across the codebase (commerce, retail-studio, brands, campaigns, briefs, org invites) each write their own inline checkOrgCapability call in a controller method; there is no shared middleware or centralised gate anywhere. Worth a real look on its own terms eventually — a codebase-wide authorisation cleanup, not something Composer's design should take on or block on.

layoutMode is renamed editTier

layoutMode collides with the engine's unrelated layout.mode positioning discriminant. Decided: editTier, not visibilityTier, since the field governs composition privilege too, not just field visibility, and visibilityTier would undersell that. The rename and the field-visibility / composition-privilege split are two different corrections to the same field, not the same fix.

The engine has already renamed the same declaration editorModeSchema

That rename is built, not proposed — only its apps/web consumer sweep remains (Resolver Engine → Positioning → editorModeSchema rename). So editTier is a second rename of shipped schema code, and one of the two names has to give. Listed as an ask in The Resolver Engine Dependency.

Stickers become ordinary Elements

Rejected: a per-Element placement tier

Every Element declaring its own minimum placement tier — "sticker" as the colloquial name for one placeable at the lowest tier — would be a third tier concept sitting alongside the two this brief already derives from a user's rung: composition privilege and the field-editTier ceiling. Under the model above, "sticker" is simply an ordinary Element, with no special mechanism of its own.

Risks

  • Whether a chain of individually-permitted Changes at a given rung can be combined to reconstruct something the tier system meant to forbid — not visible until real usage happens.
  • Whether any org currently relies, even accidentally, on the affordance-only gap's laxity for a legitimate workflow — never checked.

Open questions

  • Whether Campaign / Low / High are enough rungs, how a user or org gets granted one, and whether the editTier mapping above is the right assignment — the mapping reuses the existing four-valued enum, but it hasn't been confirmed against how those values are used in shipped manifests.
  • Which organisation's permissions govern a shared Element. Elements can be shared across organisations while grants are org-scoped; nothing says whether a user in a receiving org with the edit permission may commit a new version of an Element owned elsewhere (advancing its currentVersionId and staling the owning org's Scenes). Sharing granting place-only rights, with edit and assign checked against the owning org, is the natural rule; not decided (Brand Scoping & Brand Style).
  • What checkChangeTier walks when a fork has several authors — the whole compacted diff, or the submitted delta (The Change Model).
  • Whether a grant ever needs to vary per-Project or per-fork rather than per-org, which the capability layer doesn't express today and would need new plumbing.
  • How wrong the guessed tier boundary turns out to be once BSR uses it, and how expensive that is to correct, given real usage is the only available signal.

Done when

  • A Change[] containing one Change above the acting user's rung — by type, or by the target field's effective editTier for setInstanceField — is rejected server-side with that Change identified, regardless of what the UI showed, and a user demoted moments earlier is rejected too — verified by ordinary tests (Testing, Preview & Incident Response).