Skip to content

Foundations: The Change Model & Mutation Engine

Change[] is the single most load-bearing mechanism in this whole design: one typed, discriminated union, split by altitude, replacing the four separate override mechanisms the current system has accumulated. Every other piece of Composer — rebase, server-side enforcement, undo/redo, the future agent interface — assumes it is the only way document content ever mutates. It is a compacted current diff against a fork's base, not an append-only event log, which is what bounds its size.

What Change[] replaces

campaignChanges: Change[] collapses today's four separate override mechanisms — campaignPropsSchema, campaignLayoutOverridesSchema, campaignRemovedLayerIdsSchema, campaignAddedLayersSchema — into one typed, discriminated union. Every other mechanism in this brief assumes it exists: rebase (Fork, Rebase & Conflict Resolution) is defined as replaying this diff against a new base; server-side enforcement (Permissions & Enforcement) is defined as validating each Change against tier rules; undo/redo (Editing UI & Canvas) and the agent interface (AI / Agent Forward-Compatibility) both assume it is the only path by which content changes.

Decided: it doesn't wait for cues and keyframes

The engine owns no mutation model at all — its own docs put authored cue overrides through whatever document-mutation mechanism TemplateBuilder uses today (Resolver Engine → Cues). Deferring Composer's unification until the cue/keyframe system lands is the alternative, and it is rejected: the unification is what every other mechanism here depends on, and cues are deferred indefinitely, so waiting would mean building rebase, enforcement, and undo against the four override mechanisms this replaces. The type list below is scoped deliberately to composition and field/layout edits only. A future overrideCue or keyframe override variant is an additive union member whenever that work resumes (Keyframes & Cues, from Composer's side), not a redesign.

Two words, defined once

  • A fork is a frozen base version plus a Change[] against it. The same shape serves three purposes: authoring (a draft of an Element, Scene, or Project is a fork of its parent version, and committing it produces the next version — Identity, Versions & Schema Versioning); placement (an instance's overrides in its containing Scene or Project, living in that container's own changes); and a campaign, a fork of a Project version held at campaign permission, which has no containing document and so gets identity/version rows of its own (Storage & Versioning) — including its own brandStyleVersionId, since a campaign is what actually renders.
  • Compaction is the operation that keeps a fork's Change[] a current diff rather than a log. It runs against the fork's base, not against the previous entry, which is what lets an edit that returns a target to its base value drop out entirely rather than being recorded as a no-op; a new edit to a target that already has an entry merges into it field by field, so a layout patch setting w never drops an earlier patch's x. It is an authoring-side list operation on the fork's stored diff, distinct from the fold below and from the editor's undo history.

    One shape needs care: order is a property of the whole list, not of one instance, so a move* Change compacts against a single "order" target holding the resulting sequence, never as a per-instance index entry. Per-instance compaction would be wrong in both directions — an instance that ends up back at its base index would drop out even though the surrounding order moved, and two moves would each look like independent edits to unrelated targets.

The Change type list, by altitude

Split by altitude rather than unified, because each altitude's operations genuinely differ in shape, not just in name:

Altitude Change types
Authoring — the internals of an Element, and any altitude's own manifest and document-level properties addLayer, removeLayer, setLayerOrder, setLayerProp, setLayerLayout, setManifestBinding (expose a property, choose its control, label it, set its editTier), setDocumentProp
Scene / Overlay — both containers a layer's chain can start at addElementInstance, removeElementInstance, setInstanceOrder, setInstanceField, setInstanceLayout, rebaseInstance
Project addSceneInstance, removeSceneInstance, setInstanceOrder, addOverlayInstance, removeOverlayInstance, setOverlayCoverage, rebaseInstance, plus a shared setInstanceField
Brand (the style profile, Brand Scoping & Brand Style) setBrandStyleField — one type covering any field on the profile: a colour role, a font role, or the brand-level default Overlay reference. The same target-plus-key-plus-value shape as setInstanceField, scoped to the brand-style document rather than a layer instance. No add/remove pair, since the profile is a singleton per brand, not a collection of instances.

Notes on the ones whose existence isn't self-evident:

  • The authoring altitude is what makes a version's changes column possible. Every version row stores the Change[] that produced it from its parent (Identity, Versions & Schema Versioning), and an Element version is produced by editing layers and manifest bindings, not by placing instances. Without these types an Element version would have nothing to record and an Element draft could never rebase. The types that reach inside an Element — the layer types, and setDocumentProp on an Element — are emitted only by a user holding Element create/edit permission (Permissions & Enforcement), which is exactly why that permission is separate from the composition rungs. setManifestBinding is the exception in this row: exposing a field on this altitude's own manifest is what a Scene or Project author does routinely, so any composing rung may emit it against a Scene or Project manifest, while an Element's manifest needs the Element permission like the rest of its internals.
  • setInstanceOrder / setLayerOrder (target: the list; payload: the resulting id sequence) exist because position is list order, not an authored field — a remove-and-add of the same instance would either compact to nothing or assign a new id and orphan every override and every campaign fork's Change targeting the old one. The payload is the whole sequence rather than "this id, this index" for the reason compaction needs (above): order is a property of the list, so a per-instance index entry can neither compact nor fold correctly. A drag in the UI emits one of these carrying the order the drag produced; "move this item here" is an editor intent, not a stored Change.
  • rebaseInstance (an instance id plus the version id to point at) is how an instance inside a document catches up. A Scene version's content is immutable, so repointing instance i1 from Element v1 to v2 is a Change against the Scene that produces a new Scene version, not an edit to the frozen one. It cannot express a fork swapping its own base, because a fork's base is a property of the fork, not of anything inside it; that's the fork-level rebase below.

    Two things follow from it being an ordinary Change rather than the fork-level operation. A saved diff containing any rebaseInstance is gated as a rebase: the render-and-compare check and the mandatory Approve Migration step fire once for that save, however many instances it repoints, at save time rather than at emit (Fork, Rebase & Conflict Resolution). And an override whose target no longer exists in the new version is surfaced, never silently dropped: a setInstanceField naming a manifest field the new Element version removed becomes a low-confidence item in that same Approve Migration flow, with the same keep / remap / discard choices any other unresolvable reference gets. - setOverlayCoverage edits an existing Overlay instance's covered set without the remove-and-re-add that would orphan its overrides (Overlays). - setDocumentProp targets the document a fork is of, rather than an instance inside it: a Scene's own durationInFrames and its enter/exit motion, an Element's optional default size per aspect ratio. Without it those properties would have no mutation path at all, since every other type addresses either a layer or a placed instance. It rides the authoring row because it edits the document itself, but the rung that may emit it follows the altitude: a composing rung for a Scene or Project's own properties, Element create/edit for an Element's (Permissions & Enforcement).

setInstanceField is the same type at every altitude: its shape — a target plus a key plus a value — doesn't differ by altitude the way add and remove do.

A manifest field can never be a placed instance's own layout. setManifestBinding never exposes the x / y / w / h / order / duration of an instance in the document doing the exposing — the whole set setInstanceLayout assigns, duration included (Elements, Scenes & Projects). Otherwise the rung split would have a hole straight through it: a Scene author could bind an instance's box as a basic-tier field and a campaign user would reposition and resize instances through setInstanceField, which the rung table forbids by type — and one property would have two mutation paths with different gates, against the single-source-of-truth law. An instance's layout moves only through setInstanceLayout, at the rung that Change requires.

An Element's own internal layer layout is a different matter, and is bindable. An Element author may expose an internal layer's position or size as a manifest field — a badge that sits in one of three corners is exactly the MOGRT-style control the model is for. That is not a hole in the rule above: setInstanceLayout cannot reach inside an Element at all, so the exposed field is the only path to that property, not a second one, and its editTier is whatever the Element's author chose. The rule is about never giving one property two gated paths, not about which properties may be exposed.

flowchart LR
    subgraph fork["One fork"]
        B["frozen base version"] --> R["resolveDocument(base, changes)<br/>one pure fold, array order"]
        CH["Change[]<br/>compacted diff, fork-local ids"] --> R
        R --> D["this altitude's resolved document"]
    end
    D -. "storage-layer read of each referenced version's frozen content" .-> J["referenced instances' own content"]

The wrapper Element mechanism

There is no separate raw layer add or remove type at Scene altitude, and none is needed. Every primitive or component automatically has its own generated Element — one that contains that primitive or component alone — so everything a Scene or Overlay's layer list ever holds, from a trivial single image to a fully authored composite, is always an Element instance.

A component is the existing type: 'component' layer, and it is not a synonym for Element

Checked directly against packages/retail-studio-schema/src/zod.ts's componentLayerSchema: bespoke, hand-written render code referenced by a registry name (the betta-price-box / freshmart-price-box family), a flat sibling of the primitive layer types in a layer list. Composer wraps one the same way it wraps a primitive, and never treats it as a nesting concept.

Worth noting where the two ideas do meet: the engine's own bounds model distinguishes a composite component that merely wraps primitives, "in effect returning a snippet of template JSON" and inheriting their bounds for free, from one doing genuinely bespoke rendering, which must supply its own (Resolver Engine → Coordinate System & Sizing → Rasterization and bounds ownership). A composite component is Element-shaped in that sense. The difference that matters here is identity, not shape: an Element is a versioned, brand-scoped, independently placeable document with its own manifest, while a composite component is code in the repo behind a registry name. Which of the two a given piece of bespoke logic should become, once Elements exist, is a real question this brief doesn't settle.

Scene and Overlay altitude composition is always addElementInstance / removeElementInstance, never a second mechanism for the simple case. This generation happens only at the point something needs to be independently placed and addressed; an Element's own internal composition is still built directly from primitives and components, never from instances of each other's generated wrappers, so the rule that an Element can never contain another Element (Elements, Scenes & Projects) stays exactly as strict as before.

The resolver

Settled at the shape level: one pure fold per fork, resolveDocument(base, changes), applying each Change in array order. It is scoped to exactly one altitude's own base plus Change[] pair; it never recurses into resolving a referenced Element or Scene live. A layer's reference to an Element, or a Project's reference to a Scene or Overlay, always points at one frozen, immutable version (Fork, Rebase & Conflict Resolution), fixed until an explicit rebaseInstance swaps it, never one that updates itself on read. The resolver only ever produces its own altitude's document, including whatever field or layout overrides it holds for each instance it contains; joining those overrides against a referenced instance's own frozen, already-resolved content is a separate, simpler storage-layer read (Storage & Versioning → Resolving a reference), not something the resolver itself does.

Change[] is a diff, not an event log

Change[] is a compacted, current diff against the fork's base (see Two words, defined once for what compaction does). This is what bounds the list's size: it grows with the number of distinct things this fork has ever touched, never with the number of edit actions taken.

Three concrete commitments follow from this shape:

  • Structured targets, not string paths. A Change carries an instance id plus a field key, or an instance id alone for a layout patch, so both the fold and compaction can merge field by field rather than replacing a whole object outright, and two Changes touching different fields on the same instance don't clobber each other.
  • Ids are assigned by whoever authors the Change, never by the resolver during the fold, since a later Change in the same list may need to reference it before anything is committed.
  • Every Change carries who authored it and when — limited by compaction to "who last touched this in this fork"; version-level authorship is the durable incident granularity (Testing, Preview & Incident Response).

Undo/redo therefore cannot be a walk of the stored diff; it is a separate, uncompacted history whose entries are Changes, owned by Editing UI & Canvas.

Ids: immutable, fork-local until commit

Every Element, Scene, and layer instance gets an immutable id, assigned once at creation, never changed after. A user-facing name is a separate, mutable label that never doubles as an address. The id doesn't need to be a UUID; a small sequential value resolved through a lookup table is enough, so long as it stays unique and stable within its scope.

Decided: fork-local values, reconciled at commit

A client authoring a Change gets a value local to that fork immediately, so a later Change in the same list can reference it right away, and that value is reconciled to a final id during the same rebase or commit step that already replays a fork's Change[] against its base. This is what makes the small, sequential id scheme work: a single global counter can't be handed out safely by a client once concurrent forks exist — two forks could pick the same next value before either commits — but a fork-local value with reconciliation at commit time never has that problem, and it rides along with work the rebase step already has to do.

Two structural invariants, checked at save

Both are enforcement work, not resolver work, and both run alongside the tier check (Permissions & Enforcement) — but at a different moment, and the distinction is load-bearing.

The tier check is per Change; the structural invariants are per saved diff

Whether a user may emit a given Change never depends on any other Change, so checkChangeTier runs per item. The two invariants below are about the resulting document, so they evaluate against the fork's saved or committed diff as a whole. Otherwise authoring would be impossible: adding a background before the image it sits behind, or a flowBox before its members, would each be rejected on the way to a document that is perfectly valid once finished. A rejection still names the offending Change, by re-running the check per item over the submitted delta once the whole-diff evaluation has failed.

  • The engine's empty-container rule. A saved diff that would leave a container, at any depth, with no descendant carrying a real intrinsic size is rejected, naming the Change responsible, rather than applied and discovered broken later. The rule itself is the engine's (Resolver Engine → Coordinate System & Sizing → The empty-container rule); Change validation is where "every mutation" happens for Composer (Elements, Scenes & Projects).
  • The brand-scope intersection. Scope is derived, so a diff can't widen it past what its constituents support; what the save rejects is a diff whose derived intersection comes out empty — a container no brand can resolve. Brand Scoping & Brand Style owns the rule, including the separate sharing-list case that isn't a Change at all.

Three further rejection rules are specific to particular Change types, and all three run at save alongside the invariants above:

  • A removeSceneInstance that would leave an enumerated Overlay covering nothing is rejected rather than silently emptying it; an Overlay on the implicit all-Scenes coverage has nothing to empty (Overlays).
  • A setInstanceOrder or addSceneInstance that would break an Overlay's coverage into a non-contiguous run is rejected, because an Overlay's span is derived from what it covers and a gapped span has no length for fractional timing to resolve against (Overlays).
  • A diff that removes the target of one of its own manifest bindings — a removeElementInstance or removeLayer against something setManifestBinding exposes — is rejected unless the binding is removed too. A dangling binding would propagate upward through automatic passthrough and expose a field resolving to nothing to every Project and campaign downstream (Elements, Scenes & Projects).

What sits outside Change[]

Change[] is the only path by which document content mutates. A handful of lifecycle operations are not content edits and don't go through it: creating an identity row, committing a draft into a version, reverting a published pointer, swapping a fork's own base (the fork-level rebase — replacing the base version a fork is taken from and replaying its diff, which is how a campaign fork picks up a newer Project version, gated by whichever rung holds that fork, campaign permission included), and editing an Element's brand/organisation sharing list. Each has its own gate — the three Element permissions and composition privilege (Permissions & Enforcement), the commit rule (Identity, Versions & Schema Versioning), the off-scale manage:retail-incident capability for a revert, and the scope-intersection check on sharing-list narrowing (Brand Scoping & Brand Style) — and none of them is reachable by an agent producing Change objects (AI / Agent Forward-Compatibility). Naming them keeps "one mutation path" an accurate claim about content rather than an overclaim about everything. Note the two rebases are different operations: repointing an instance inside a document is rebaseInstance, an ordinary Change; repointing a fork's own base is the lifecycle operation above.

Validation shape

Decided at the shape level, not yet built: Change validation is a shared envelope (a discriminated union tag plus common fields) with a per-type payload schema — a Zod discriminated union, matching how the rest of the stack already validates external input. It validates structure only; every tier decision, including the target field's editTier for a setInstanceField, is a capability lookup the schema can't express and belongs to checkChangeTier (Permissions & Enforcement).

One shape decision still settling

  • setInstanceLayout waits for flowBox and pin to be schema-stable first — decided, leaning strongly rather than fully locked — rather than being designed against the engine's current draft shape and risking a rework once those land for real (The Resolver Engine Dependency). (Overlay coverage is settled, not settling — the set of Scene instances an Overlay covers, per Overlays.)

Risks

  • Individually valid Changes composing into an invalid or nonsensical end state. Partially covered by the rebase trigger, still open for a single fork (Testing, Preview & Incident Response).
  • Unifying Change[] before cues and keyframes exist may lock in a shape that fits them badly once they do. Deferring the unification until after them was the alternative; Composer decided not to wait. Not yet checked either way.

Open questions

  • Whether Change[] depends on the engine's flowBox / pin being schema-stable — live, because of setInstanceLayout above — which would move it off the root of the dependency graph (Delivery Sequencing).
  • How wrapper Elements exist as identity/version rows. Every Element is a DB identity with brand ownership and a schemaVersion, and creating one is a separately grantable permission; a user with composition privilege but no create permission dragging in a plain image needs a version id for a wrapper that may not exist yet. Pre-generated, org-neutral catalogue rows keyed by primitive type (with the asset set via setInstanceField), or on-demand rows owned by the placing brand and exempt from the create permission — not decided, and it has to reconcile with "no different kind of thing wearing the same name" (Storage & Versioning).
  • What the enforcement gate walks, and what stops two saves colliding. The only Change[] defined is the fork's whole compacted diff; if a High-tier user's addElementInstance sits in a draft a Low-tier user later saves, a gate that walks the whole diff rejects the save. The gate presumably runs on the delta between the stored fork and the submission, with compaction server-side — which is also where a fork revision counter would go, since nothing today stops two campaign users' concurrent saves against one fork from overwriting each other (the commit rule protects versions, not fork saves; Concurrency & Editing Safety). Not yet stated.
  • The concrete JSON shape of an Element's own internal structure. The authoring altitude above says what edits it, not what it looks like. A Composer-tier Change still never reaches inside an Element, so setInstanceField only ever addresses an instance id plus an exposed manifest field key; what needs settling is the layer-tree encoding those authoring Changes target.

Done when

  • resolveDocument folding two Changes to the same instance merges field by field, and the compaction step drops an edit that returns a target to its base value — both verified by ordinary unit tests, an ordinary CI merge gate (Testing, Preview & Incident Response).
  • Saving a diff is rejected, with the offending Change identified, if it would empty a container of intrinsic size, widen or empty a container's brand scope, leave an enumerated Overlay covering nothing, break an Overlay's coverage into a non-contiguous run, or strand one of its own manifest bindings.