Skip to content

Foundations: Brand Scoping & Brand Style

Elements belong to a brand, not a global catalogue; Scenes and Projects inherit that scope as an intersection of what they're built from; and a brand carries one explicit style profile that Elements reference by role rather than by copied value. This page owns all three.

Elements are scoped to a brand, not a global catalogue

Decided, per direct instruction

An Element belongs to a brand, and through it an organisation, not a single global catalogue every org draws from equally. The common case — expected roughly ninety percent of the time — is a brand-specific Element, authored for and only ever offered to one brand. Sharing an Element across brands, or across organisations, is a real, supported case, not a rare exception, but it's an explicit choice made in an administration panel: an author picks which brands and organisations an Element belongs to, rather than Elements being global by default.

This feeds directly into Storage & Versioning: an Element's ownership needs a brand and/or organisation reference plus a sharing list, not just a bare id. It also bears on the swap-suggestion idea in AI / Agent Forward-Compatibility — the catalogue an agent searches should presumably be scoped the same way — though that page doesn't settle it yet.

Scenes and Projects carry the same scope, as an intersection

A Scene or Project's valid scope is constrained by what it's built from, not independently chosen: it can only be scoped to a brand that every one of its constituent instances, all the way down, is itself also scoped to. Concretely, if half a Scene's Elements are shared across Brand A and Brand B and the other half are Brand B only, that Scene can only be scoped to Brand B — scoping it to Brand A would leave some of its own Elements unresolvable for a Brand A viewer, an access leak or a broken reference, neither acceptable.

flowchart TB
    E1["Element 1<br/>brands: A, B"] --> S["Scene<br/>valid scope = A ∩ B ∩ B = B"]
    E2["Element 2<br/>brands: B"] --> S
    E3["Element 3<br/>brands: A, B"] --> S
    S --> P["Project<br/>valid scope = intersection of its Scenes"]

The rule is recursive: a Scene's valid scope is the intersection of its Element instances' scopes, and a Project's is the intersection of its Scene instances'.

Scope is derived, not authored. A container's scope is recomputed as that intersection whenever its fork is saved, rather than stored as a value someone sets and the validator then polices. That matters for a case the stored reading makes impossible: adding a Brand-A-only Element to a Scene currently scoped {A, B} is a perfectly reasonable composition — it narrows the Scene to {A} — and under a stored scope it would be rejected with no way to narrow first. What validation rejects is therefore never an ordinary narrowing; it is any Change that would leave a container scoped wider than its constituents support, which after derivation can only happen from the other direction: removing a brand from an Element's sharing list (below), or placing the container itself somewhere its own scope can't satisfy. The one narrowing that is rejected is a narrowing to nothing: a derived intersection of the empty set means a container no brand can resolve and nobody can see, so combining a Brand-A-only Scene with a Brand-B-only one in a single Project is refused where it's proposed rather than producing a Project scoped to no brand at all. Caught at validation, never left to leak at render time — the same enforcement discipline the empty-container rule gets (The Change Model). This also answers the brand side of Scene-picking (Overlays): picking a Scene into a new Project is only valid if the Project's own scope is within what the picked Scene's valid scope allows.

The sharing list is the other side of the same invariant. An Element's sharing list lives on its identity row and is edited in the admin panel, not through a Change — so narrowing it (removing Brand B from an Element that Brand-B-scoped Scenes already contain) is the one edit that can break the intersection without any Change being proposed. It is therefore gated by the same check, run over every reachable reference to that Element: the versions each identity's currentVersionId points at, plus every live fork. A narrowing that would leave one of those scoped to a brand the Element no longer supports is rejected where it's proposed, the same way a Change would be.

Reachability is the load-bearing word. Every version ever committed is immutable and keeps its references forever, so a check over all committed rows could never pass again once any historical version referenced the Element — a superseded Scene v1 would veto every future narrowing. The check needs the on-demand reverse-reference query (Fork, Rebase & Conflict Resolution), which is alpha-scope for this reason even though the proactive notifier built on the same data is deferred. This is one of the lifecycle operations that sit outside Change[] (The Change Model), gated by the Element assign permission (Permissions & Enforcement).

Composer's permission tier, by contrast, stays org-scoped, not brand-scoped — a considered decision, made on the Permissions & Enforcement page.

Brand style: what exists, and the gap

Investigated at Blair's request before designing anything new: how comprehensive brand assets and brand styling already are. The honest answer: partially there, and not there in the way the ask actually needs.

What already exists. A brand row (organisation_id, name, nothing else) has three satellite tables hanging off it, each brand_id-scoped, S3-backed, with real CRUD APIs and picker UI already shipped: brand_colour (a flat, unordered swatch list of hex values), brand_font (uploaded font files, matched by family name), and brand_sticker (uploaded image assets). None of this is a placeholder; all three are live infrastructure a Composer Element could sit on top of.

What's missing is the variable layer, not the assets. Three specific gaps:

Gap What it means today
No semantic roles Colours and fonts are flat, named lists a human picks from visually — the brand's swatches, never roles like "primary," "heading font," or "accent." There is no single thing that is the brand's primary colour.
Colour selection breaks the link the moment it's made Picking a brand swatch for a text layer copies the literal hex onto the layer; no reference back survives. If the swatch is later edited, every layer set from it stays as it was. The opposite of "reference a brand colour, have it update everywhere."
Font matching is real indirection, but the wrong kind A layer's font.family resolves against the brand's font library by exact family-name string match at render time — asset-lookup indirection, not role-based: renaming a family breaks every layer using it, and there's no concept of "the brand's heading font."

The one mechanism worth building on: assetRefSchema. Retail Studio already has a genuine portable reference pattern — a discriminated union of { kind, ...ref } shapes, used today for brand audio ({ kind: "brand-audio", slug }), resolved to the actual asset at render time rather than the asset's location being hardcoded onto the layer. Brand stickers don't use it yet (a plain stickerId string), so brand audio is the one concrete precedent this extends. Extending this same union with { kind: "brand-colour", role } and { kind: "brand-font", role } variants, rather than inventing a second reference mechanism, is the "one way to do a thing" law applied directly: this schema already has exactly one pattern for a layer pointing at a brand asset instead of embedding it, and colour and font tokens are the same problem.

The style profile

Decided, per direct instruction: one explicit style-profile object per brand

A fixed set of named roles — primary colour, secondary colour, heading font, body font, the brand-level default Overlay (Overlays), and so on — that colour and font AssetRef variants point into, not each Element or Scene referencing whichever brand colour or font it wants by id with no shared vocabulary. One location holds the entire brand specification, and every Element author designs against the same predictable vocabulary rather than privately deciding what "the brand's main colour" means. Settling this now, before Element authoring starts in earnest, avoids retrofitting a role vocabulary onto Elements authored against loose references. The concrete role set — which roles, how many — isn't specified here; a smaller, later decision than the shape.

Mutation is one Change type, setBrandStyleField, admitted by the off-scale manage:brand-style capability (Permissions & Enforcement); storage is the retail_brand_style_profiles / _versions pair (Storage & Versioning).

Brand style values resolve and freeze at commit, the same discipline as everything else — not live at render time. The style profile is itself a versioned, frozen thing exactly like an Element, Scene, or Project; something that references "the brand's primary colour" resolves that against the brand-style version its own base version was pinned to, carried forward unchanged on commit, and renders exactly that from then on. Carried forward, not re-resolved: an ordinary content edit committed after the brand published a new profile version still renders the old colour, because otherwise an unrelated text fix would silently repaint the creative and the rebase gate below would be dead code. Only the first version of a brand-new item picks up whatever profile version is current at its creation. Concretely, that pinned version is recorded: every Element, Scene, and Project version row carries a brandStyleVersionId alongside its own lineage (Identity, Versions & Schema Versioning), which is both what the read resolves role references against and what the staleness badge compares to the brand profile's currentVersionId. The style profile's own version rows carry no such column — the profile is the brand-style version. content keeps the role reference, never a resolved hex, so the same content renders correctly under whichever profile version it is paired with. A later brand colour or font edit doesn't retroactively repaint anything already committed; it becomes visible once whatever references it rebases onto the newer brand-style version — the same mechanism, and the same visual-regression gate, that governs picking up any other upstream change (Fork, Rebase & Conflict Resolution). "Updates across the board" undersells what's true: it updates everywhere once rebased, not instantaneously across every historical render, which is exactly what keeps a historical render legible as what it actually was.

Adopting a newer brand style is its own rebase, and it cascades. brandStyleVersionId is stamped at commit and never edited in place, so nothing a Change can do moves it. Picking up a new brand-style version is the fork-level rebase (The Change Model → What sits outside Change[]) pointed at the brand-style profile rather than at a content base: it reseeds the fork with the newer brandStyleVersionId, replays its diff, and goes through the same confidence score and Approve Migration step as any other rebase. Because it is a rebase of the fork rather than an edit inside an Element, the rung holding the fork may run it — a High-tier user can adopt a new brand colour for a Scene without Element-edit permission.

What it doesn't do is propagate on its own. A colour role referenced inside an Element resolves against that Element version's own pin, so adopting it there needs a new Element version (Element edit permission), and then each Scene holding it, then each Project, then each campaign fork, picks that up through its own gated rebase. "It updates everywhere once rebased" is accurate but easy to read as one action: it is one rebase per altitude that actually references the role, each with its own approval. Whether that cascade wants a bulk tool is the same question the schema-migration bulk job raises, and is equally undesigned (Identity, Versions & Schema Versioning).

Risks

  • The role vocabulary is unspecified. Elements authored before it's fixed would need the exact retrofit the style profile exists to avoid; the role set needs deciding before Element authoring starts, not after.
  • The sharing-list gate needs a reverse-reference query the staleness design deliberately avoids (above). It is a narrower, on-demand version of the deferred "who's affected" problem, but it is not free, and the narrowing edit can't ship without it.

Open questions

  • Where "the brand" binds for a shared Element or a multi-brand Project. A role reference (brand-colour: primary) inside an Element shared across Brands A and B has no single brand to pin at the Element's commit; and a Project scoped {A, B} "automatically" carrying Brand A's default Overlay would narrow its intersection to nothing. The natural rule is that a render — a campaign, or a Project as delivered — is bound to exactly one brand, and brand-style roles and the default Overlay bind and freeze against that brand's style version at the point the campaign or Project commits, not at a shared Element's commit. Not decided; the brief nowhere states that a render context is one brand.
  • Which organisation's permissions govern a shared Element — owned by Permissions & Enforcement.
  • The concrete role set for the style profile.
  • Whether the agent's swap-suggestion catalogue scopes by brand the same way (AI / Agent Forward-Compatibility).