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:
- Creating a new Element from primitives.
- 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.
- 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 arole_groupa user holds per organisation (user_group_membershipjoined torole_group.capabilities), resolved server-side intocapabilitiesByOrg[orgId](apps/api-v1/src/lib/permissions.ts,getUserPermissions/getCapabilitiesByOrg), and checked withcheckOrgCapability/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, withauthorreserved 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/clampLayoutModeplususeMaxLayoutMode/useEffectiveLayoutMode— "a ceiling derived from capability, a chosen value clamped to it" — real, working code, and the pattern theeditTierceiling 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
editTiermapping 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
currentVersionIdand 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
checkChangeTierwalks 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 effectiveeditTierforsetInstanceField— 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).