{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://www.braaand.ai/template.schema.json",
  "title": "Braaand template document",
  "description": "The JSON document shape shared by system templates, brand templates, and creatives. Generated from lib/types.ts by `pnpm docs:schema` — do not edit by hand. Prose reference: https://www.braaand.ai/docs/templates/template-json-reference",
  "anyOf": [
    {
      "$ref": "#/definitions/Template"
    },
    {
      "$ref": "#/definitions/Creative"
    }
  ],
  "definitions": {
    "Template": {
      "type": "object",
      "properties": {
        "kind": {
          "type": "string",
          "enum": [
            "branded",
            "concept"
          ],
          "description": "\"branded\" (default) — typography and color cascade from the brand (typeStyles + colorRoles, keyed by element.role + variant). \"concept\" — the brand cascade is bypassed. The document carries its own `typeStyles` + `roleColors` instead, and the cascade reads from there. Use for fake-UI / parody layouts (Notes, iMessage, Tinder, leaked PDF, etc.) where the joke depends on looking like the thing being parodied. Brand assets (logo, useBrandDefault, var(--brand-*)) still opt in."
        },
        "variant": {
          "type": "string",
          "description": "Which color scheme this document renders in — a scheme id, not just a tonal toggle. The built-ins \"dark\" and \"light\" resolve straight from the brand's `colorRoles` (*OnDark / *OnLight). Any other value is the id of a custom  {@link  ColorScheme }  in `brand.colorSchemes`, resolved at render time via `resolveSchemeContext()` (lib/color-schemes.ts) into a tonal base + a derived colorRoles. Defaults to \"dark\". Unused for `kind: \"concept\"`. (Field kept named `variant` for storage/API continuity; surfaced as \"color scheme\" everywhere user- and agent-facing.)"
        },
        "themeId": {
          "type": "string",
          "description": "Which  {@link  Theme }  this document resolves against — a custom theme id in `brand.themes`, or undefined/\"default\" for the brand's default theme (the brand root styling). The theme swaps colorRoles + typeStyles + baseStyles before the color-scheme cascade runs (`applyTheme` in lib/themes.ts), so a theme changes the full look (fonts, button shape, colors) while `variant` still recolors on top. Unused for `kind: \"concept\"`."
        },
        "typeStyles": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TypeStyle"
          },
          "description": "Per-role typography presets carried by the document itself. Mirrors `BrandConfig.typeStyles`. For concept templates this is the only source of typography (brand cascade is bypassed). Resetting a typography field on an element pulls it back from here, and new text elements seed defaults from the matching role here.\n\nBranded templates can also set this to override brand typography locally, but it's optional and unusual — most branded templates just use the brand's typeStyles directly."
        },
        "roleColors": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          },
          "description": "Per-role color map carried by the document itself. Flat (role → color hex/CSS) since concept templates don't use the brand's variant-suffixed `colorRoles`. The cascade reads from here for concept templates so resetting a color field on an element brings back the document's intended color."
        },
        "sourceSystemTemplateId": {
          "type": "string",
          "description": "Non-authoritative back-reference to the system template this document was instantiated from. Set on brand templates at instantiation time and propagated to ads at clone time. Used by the editor's asset picker to default-filter the Stock tab to the source template's bundled assets. `null`/missing means \"no source\" (e.g. brand templates created from scratch). Editing the system template does NOT propagate to documents that reference it — the pointer is purely for discovery."
        },
        "agentInstructions": {
          "type": "string",
          "description": "Long-form LLM-facing briefing on how to use this template. Multi- paragraph copywriting playbook with structural intent, do/don't patterns, and ideally worked examples per industry. Distinct from the short `description` which is human-facing card copy.\n\nConsumed by the workflow pipeline's `editAdAgent` (highest leverage) and `selectTemplates` so the model understands what the template is FOR, not just what it looks like. Propagates from system template → brand template (instantiate) → ad (materialize) so each snapshot carries its own briefing.\n\nAuthored on the template detail page via a dedicated modal — never shown on cards / catalogs."
        },
        "formats": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/AdFormat"
          }
        },
        "elements": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TemplateElement"
          }
        },
        "guides": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TemplateGuide"
          }
        },
        "styles": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/CSSStyle"
          }
        },
        "clusters": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/Cluster"
          },
          "description": "Authorial layout groupings. Each cluster declares \"these elements belong together — stack them with this gap, anchor as a group to this guide.\" The deterministic layout-repair pipeline applies them after every content change so headline/subheading/CTA rhythm survives copy edits. Empty/missing means no group semantics for this template; repair falls back to element-level anchors + the brand-fit cascade.\n\nA cluster's `memberIds` may reference either elements OR groups (single level of containment) — the cluster-layout rule resolves each id to either an element rect or a group's union bbox."
        },
        "groups": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/Group"
          },
          "description": "Frozen sub-arrangements that expose a single bounding box. Members' authored offsets are preserved; moving the group translates every member by the same delta. Used so a `[checkbox, text]` row can be the unit a cluster stacks (cluster gap → row spacing) and so the agent can duplicate the row as one thing.\n\nSingle level of containment only — a group's `memberIds` are element IDs (no nested groups). Each element belongs to at most one group. Each group belongs to at most one cluster."
        },
        "brandFit": {
          "$ref": "#/definitions/BrandFitDocConfig",
          "description": "Per-document BrandFit configuration. Each rule (anchor-snap, cluster- layout, overflow, widow, etc.) defaults to a sensible enabled/auto state declared in code; this map overrides those defaults per rule, with optional per-format overrides. A rule with no entry here uses its default. Persisted with the doc; cloned through instantiate + materialize via JSON deep-clone."
        },
        "version": {
          "type": "number",
          "description": "Optimistic-concurrency counter. A dedicated DB column (NOT part of the doc JSONB) that every save bumps. Present on docs read from the store; `saveTemplate` / `saveCreative` compare-and-set against it and throw  {@link  DocVersionConflictError  }  when another writer got there first, so callers re-read and re-apply instead of silently clobbering. Undefined on freshly constructed docs (create paths insert at 1)."
        },
        "id": {
          "type": "string"
        },
        "brandId": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "description": {
          "type": "string"
        },
        "categories": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Free-text taxonomy bucket(s) — used by the templates listing's filter bar to group templates by archetype (e.g. \"Event\", \"Image background\", \"Text-based\"). A template can belong to several categories at once; the filter UI applies OR semantics across selections. No hardcoded enum — anything the user types becomes a category, deduped case-insensitively for filtering."
        },
        "createdAt": {
          "type": "string"
        },
        "updatedAt": {
          "type": "string"
        }
      },
      "required": [
        "brandId",
        "createdAt",
        "elements",
        "formats",
        "id",
        "name",
        "updatedAt"
      ]
    },
    "TypeStyle": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string"
        },
        "label": {
          "type": "string"
        },
        "description": {
          "type": "string"
        },
        "family": {
          "type": "string"
        },
        "weight": {
          "type": "number"
        },
        "assetFile": {
          "type": "string"
        },
        "letterSpacing": {
          "type": "number"
        },
        "textTransform": {
          "type": "string"
        },
        "lineHeight": {
          "type": "number"
        },
        "defaultSize": {
          "type": "number",
          "description": "BIRTH default font size for a fresh element of this role, in px on the 1:1 canvas (1080) — scales to other formats via typeDim (short edge, blended up on wide formats). Consumed only at element CREATION (add-menu, add_element, compose) — deliberately NOT part of the render-time cascade: size stays per-format layout owned by each template, so a brand-standard size can never resize authored layouts. Unset = the system type scale."
        },
        "specimenText": {
          "type": "string",
          "description": "Sample text shown in the brand-config Typography preview. Brand authors can override this to make the specimen reflect the type style's intent (e.g. \"Quote of the day\" for a quote style). Falls back to a generic sample if unset. Optional and never required by the renderer."
        }
      },
      "required": [
        "id",
        "label",
        "family",
        "weight"
      ]
    },
    "AdFormat": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        },
        "width": {
          "type": "number"
        },
        "height": {
          "type": "number"
        },
        "label": {
          "type": "string"
        }
      },
      "required": [
        "name",
        "width",
        "height",
        "label"
      ]
    },
    "TemplateElement": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "enum": [
            "text",
            "image",
            "logo",
            "shape",
            "button",
            "frame"
          ],
          "description": "\"frame\" is an image placeholder. Renders the same `<img>` pipeline as `image` (mask via overflow:hidden + borderRadius, object-fit:cover, objectPositionX/Y + imageZoom for pan/zoom), with two distinctions:  1. Empty `content` shows a placeholder UI (gray box + frameHint label)     instead of resolving via the brand-default cascade (unless     `useBrandDefault: true` is also set).  2. When content changes (drop, library pick, AI write) and     `frameAutoFit !== \"none\"`, the server auto-fits the new asset by     solving for objectPositionX/Y + imageZoom from the asset's POI →     `framePoi` target, per-format. Math lives in `lib/frame-fit.ts`. Any code that asked \"is this an image?\" for the purpose of running image-style brand-fit rules or picking an `<img>` render path should use `isImageLike(el)` so frames inherit that behavior."
        },
        "role": {
          "type": "string"
        },
        "label": {
          "type": "string"
        },
        "description": {
          "type": "string"
        },
        "content": {
          "type": "string"
        },
        "frameHint": {
          "type": "string",
          "description": "Frame-only: human-readable description of what should fill this slot (\"Politician headshot\", \"Product photo on white\", \"Logo of partner\"). Surfaced in the placeholder UI and in AI tool context so swaps stay on-intent. Ignored for non-frame elements."
        },
        "frameAutoFit": {
          "type": "string",
          "enum": [
            "poi",
            "none"
          ],
          "description": "Frame-only: how to position the asset inside the frame when content changes. \"poi\" (default) runs `fitAssetToFrame` to land the asset's subject on `framePoi`. \"none\" leaves `objectPositionX/Y` + `imageZoom` untouched so the user / agent can hand-tune."
        },
        "framePoi": {
          "type": "object",
          "properties": {
            "x": {
              "type": "number"
            },
            "y": {
              "type": "number"
            }
          },
          "required": [
            "x",
            "y"
          ],
          "description": "Frame-only: where in the frame box the asset's POI should land, normalized 0..1 (frame-local). Default { x: 0.5, y: 0.5 } — center. Per-format overridable so a 9:16 frame can target subject upper-third while 1:1 stays centered. Read by `fitAssetToFrame`."
        },
        "binding": {
          "$ref": "#/definitions/ContentBinding",
          "description": "When set, overrides `content` at render time with a value resolved from another element's referenced asset metadata. Only meaningful on text/button elements."
        },
        "constraints": {
          "type": "object",
          "properties": {
            "minChars": {
              "type": "number",
              "description": "Character-count budget for text/button copy. Authored on every branded text + button element so the Copy Agent can write copy that fits without LLM guessing. Validator enforces presence + `minChars <= idealChars <= maxChars`. These values are shared across formats by default; a format only differs when an author explicitly sets min/ideal/max chars in `formatOverrides[fmt].constraints`. Read through `resolveConstraintsForFormat()`, never raw."
            },
            "maxChars": {
              "type": "number"
            },
            "idealChars": {
              "type": "number"
            },
            "contentEditable": {
              "type": "boolean"
            },
            "maxLines": {
              "type": "number"
            },
            "guidelines": {
              "type": "string"
            },
            "imageGuidance": {
              "type": "string"
            },
            "fontSizeRange": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "minItems": 2,
              "maxItems": 2
            },
            "idealFontSize": {
              "type": "number",
              "description": "The designed/optimal font size for this element on this format — what brand-fit grows back toward after copy shortens, distinct from `fontSizeRange` which bounds how far auto-fit may shrink or grow. Per-format via `formatOverrides[fmt].constraints`."
            },
            "anchors": {
              "type": "array",
              "items": {
                "$ref": "#/definitions/AnchorBinding"
              },
              "description": "Declarative layout anchoring. Each binding pins one EDGE of this element to a `TemplateGuide` (referenced by `guideId`). The deterministic layout-repair pipeline reads these during repair and snaps the named edge to the guide's resolved position for the current format. Multiple anchors are evaluated in order; a later anchor wins on conflict."
            }
          }
        },
        "cssClass": {
          "type": "string"
        },
        "logoForm": {
          "type": "string"
        },
        "logoTreatment": {
          "type": "string",
          "enum": [
            "on-dark",
            "on-light",
            "full-color"
          ]
        },
        "logoColor": {
          "type": "string"
        },
        "logoAssetId": {
          "type": "string"
        },
        "useBrandDefault": {
          "type": "boolean",
          "description": "Image-only opt-in: when true AND this element has no content override, the renderer fills the image with the brand's `defaultImageAssetId`. Lets a system template author showcase a layout with a representative image that resolves to each brand's on-brand default on instantiation. Ignored for non-image elements."
        },
        "useRowImage": {
          "type": "boolean",
          "description": "Image-only opt-in, and the SIBLING of `useBrandDefault`: when true, a \"For each\" node writes the current  {@link  SetRow } 's `assetId` into this element's content as it mints that row's creative. Declarative per TEMPLATE rather than configured on the node, so one For each node can feed several upstream templates whose portrait slots have different element ids.\n\nA row-pinned image is neither the brand default nor a swappable slot, so the Imagery agent's \"brand-default\" scope skips it automatically (`splitImageTargets` in lib/factory/imagery-targets.ts) — which is what stops a candidate's portrait being replaced with stock.\n\nAuthor a candidate/speaker portrait slot as `useRowImage: true` and NOT `useBrandDefault: true`. Ignored for non-image elements."
        },
        "knockout": {
          "type": [
            "string",
            "boolean"
          ],
          "enum": [
            "auto",
            "auto-accent",
            "dark",
            "light",
            "accent-dark",
            "accent-light",
            false
          ],
          "description": "Per-line knockout treatment for text elements.\n- \"auto\" (default) — contrasts with template variant: dark variant → light box, light variant → dark box\n- \"auto-accent\" — same but using the brand's accent color for the box\n- \"dark\" | \"light\" | \"accent-dark\" | \"accent-light\" — explicit pin; does not flip with variant\n- false — explicitly off (useful for overrides)"
        },
        "ctaTreatment": {
          "type": "string",
          "enum": [
            "on-dark",
            "on-light"
          ],
          "description": "Button-only: pin the CTA treatment to a specific side. When set, disables the variant-aware class swap for this element (sys-cta-on-dark stays sys-cta-on-dark even if the template's variant is \"light\"). When undefined, the CTA's class swaps with variant as before."
        },
        "tone": {
          "type": "string",
          "enum": [
            "contrast",
            "dark",
            "light"
          ],
          "description": "Which SURFACE this element sits on. A color scheme describes two surfaces: the canvas (its tonal `base`) and the inverted contrast surface (the scheme's contrast chips — the light \"sign\" inside a dark design). `tone` redefines which one the element reads, and every \"auto\" behavior — text color cascade, shape background fill, panel/CTA class swap, `knockout: \"auto\"`, icon tint, logo treatment derivation — follows the toned surface instead of the canvas.\n- \"contrast\" — the scheme's inverted surface; flips WITH the scheme\n- \"dark\" | \"light\" — absolute pin, regardless of scheme\n- undefined — follow the scheme base (default) Explicit pins still win over tone: `ctaTreatment`, literal knockout values (\"dark\"/\"light\"/\"accent-*\"), and explicit colors are never overridden. Deliberately invisible to scheme-drift detection (like `knockout`): tone is authorial surface intent, not a color override, and survives scheme snap-back. No-op on concept documents (their roleColors have no tonal sides)."
        },
        "fill": {
          "type": "string",
          "const": "text",
          "description": "Shape-only: what the shape's fill FOLLOWS when no explicit backgroundColor is set. `\"text\"` fills with the scheme's text color for the surface the shape sits on (textOnDark/textOnLight, tone-aware) — for separators, rules, and dividers that should read like the copy next to them, on every scheme. The semantic sibling of the icon tint cascade. Explicit backgroundColor always wins. Ignored on non-shape elements and concept docs."
        },
        "locked": {
          "type": "boolean"
        },
        "clusterGapBefore": {
          "type": "number",
          "description": "Absolute pixel gap injected BEFORE this element when laid out as a cluster member. Replaces the cluster's default `gap` for the spacing immediately above (vertical cluster) or to the left (horizontal cluster) of this element. Ignored when the element is the first member of its cluster. Honors per-format override via `formatOverrides[fmt].clusterGapBefore`.\n\nAuthorial intent — \"this element needs breathing room from the one before it.\" Common case: CTA further down than the headline → subheadline pair sit apart."
        },
        "styles": {
          "$ref": "#/definitions/ElementStyles"
        },
        "formatOverrides": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/definitions/FormatOverride"
          },
          "description": "Per-format overrides of any field that makes sense to vary per format — content, cssClass, knockout, ctaTreatment, tone, styles, logo properties, useBrandDefault, hidden. Sparse: each format key carries only the fields explicitly overridden. Resolved at render time as `formatOverrides[fmt].field ?? element.field` (styles shallow-merge)."
        }
      },
      "required": [
        "id",
        "type",
        "role",
        "label",
        "content",
        "styles"
      ]
    },
    "ContentBinding": {
      "type": "object",
      "properties": {
        "sourceElementId": {
          "type": "string",
          "description": "Element whose `content` URL should be resolved to an asset."
        },
        "segments": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/ContentBindingSegment"
          },
          "description": "Composed segments. When present, this drives the resolved output; the legacy `field`/`prefix`/`suffix` are ignored. Resolved segments are joined by `separator` only between non-empty entries."
        },
        "separator": {
          "type": "string",
          "description": "Text inserted between consecutive RESOLVED segments."
        },
        "separatorWeight": {
          "type": "number",
          "description": "Optional font weight for the separator."
        },
        "field": {
          "$ref": "#/definitions/ContentBindingField",
          "description": "Legacy single-field shape — used only when `segments` is absent. Preserves every existing binding without a migration."
        },
        "prefix": {
          "type": "string"
        },
        "suffix": {
          "type": "string"
        },
        "fallback": {
          "type": "string",
          "description": "Content to use when NO segment resolves. Empty string hides the element-level text."
        }
      },
      "required": [
        "sourceElementId"
      ]
    },
    "ContentBindingSegment": {
      "type": "object",
      "properties": {
        "before": {
          "type": "string",
          "description": "Text emitted directly before the field value. Only renders when the field resolves to non-empty content."
        },
        "beforeWeight": {
          "type": "number",
          "description": "Optional font weight (100–900) for `before`. Inherits the element's weight when unset."
        },
        "field": {
          "$ref": "#/definitions/ContentBindingField",
          "description": "Which asset metadata field to read for this segment."
        },
        "valueWeight": {
          "type": "number",
          "description": "Optional font weight for the field value itself."
        }
      },
      "required": [
        "field"
      ],
      "description": "One field segment of a composed binding. Each segment resolves independently — empty fields are skipped entirely (including the `before` text), so a missing photographer doesn't leave a dangling \"Photo: , Unsplash\". Per-segment font weights let the renderer emit mixed-weight output (\"Photo: \" bold + \"Jane Doe, Unsplash\" regular)."
    },
    "ContentBindingField": {
      "type": "string",
      "description": "Which value a text element's `binding` resolves to. TWO sources, and the distinction is where the data lives rather than what it means:\n\nASSET-derived — read through the element the binding points at (`sourceElementId`), i.e. \"whatever photo sits in that frame\":\n- `photographer` / `copyright` / `source` — read straight off   `Asset.metadata`. Credit-line use case.\n- `name` — the asset's `name` field.\n- `person.*` — read off `Asset.metadata.person` (see `PersonMetadata`).   `person.fullName` is virtual: `firstName + \" \" + lastName`. Used for   speaker / employee cards — a text element bound to `person.title`   auto-updates when the headshot in its frame is swapped.\n\nROW-derived — read off the DOCUMENT's own `row` ( {@link  SetRow } ), stamped by a \"For each\" node:\n- `row.label` — the row's display label (\"Frida Karlsson\", \"Stockholm\").\n- `row.<key>` — any key in the row's `fields`. Open by design: a Set's   columns are the author's, not a fixed vocabulary.\n\nRow bindings need no `sourceElementId` (the row is on the doc, not in a slot), which is why a data-only Set with no images still drives copy.\n\nThis is the mechanism that keeps facts out of the model's hands: a bound element is excluded from every copy call, so a real person's name or title can never be rewritten or invented."
    },
    "AnchorBinding": {
      "type": "object",
      "properties": {
        "guideId": {
          "type": "string",
          "description": "References a guide in `BraandDocument.guides` by id."
        },
        "edge": {
          "type": "string",
          "enum": [
            "left",
            "right",
            "top",
            "bottom",
            "center-x",
            "center-y"
          ],
          "description": "Which edge of the element snaps to the guide.\n- \"left\" / \"right\" → snap x or x+width to a vertical guide's position\n- \"top\" / \"bottom\" → snap y or y+height to a horizontal guide's position\n- \"center-x\" → snap x + width/2 to a vertical guide\n- \"center-y\" → snap y + height/2 to a horizontal guide"
        }
      },
      "required": [
        "guideId",
        "edge"
      ],
      "description": "One end of a declarative anchor binding. Pin a specific EDGE of an element to a `TemplateGuide`. The deterministic layout-repair pipeline reads these and snaps the named edge to the guide's resolved position for the active format. Authored on `TemplateElement.constraints.anchors`."
    },
    "ElementStyles": {
      "type": "object",
      "properties": {
        "x": {
          "type": "number"
        },
        "y": {
          "type": "number"
        },
        "width": {
          "type": "number"
        },
        "height": {
          "anyOf": [
            {
              "type": "number"
            },
            {
              "type": "string",
              "const": "auto"
            }
          ]
        },
        "zIndex": {
          "type": "number"
        },
        "visible": {
          "type": "boolean"
        },
        "rotation": {
          "type": "number"
        },
        "fontSize": {
          "type": "number"
        },
        "fontFamily": {
          "type": "string"
        },
        "fontWeight": {
          "type": "number"
        },
        "lineHeight": {
          "type": "number"
        },
        "letterSpacing": {
          "type": "number"
        },
        "textTransform": {
          "type": "string"
        },
        "textAlign": {
          "type": "string"
        },
        "color": {
          "type": "string"
        },
        "backgroundColor": {
          "type": "string"
        },
        "borderRadius": {
          "type": "number"
        },
        "padding": {
          "type": "number"
        },
        "paddingX": {
          "type": "number"
        },
        "paddingY": {
          "type": "number"
        },
        "opacity": {
          "type": "number"
        },
        "boxShadow": {
          "type": "string"
        },
        "shapeType": {
          "type": "string",
          "enum": [
            "rectangle",
            "circle"
          ]
        },
        "objectFit": {
          "type": "string"
        },
        "objectPositionX": {
          "type": "number"
        },
        "objectPositionY": {
          "type": "number"
        },
        "imageZoom": {
          "type": "number"
        },
        "imageOffsetX": {
          "type": "number"
        },
        "imageOffsetY": {
          "type": "number"
        },
        "overflow": {
          "type": "string"
        },
        "tintColor": {
          "type": "string"
        },
        "blur": {
          "type": "number"
        },
        "brightness": {
          "type": "number"
        }
      },
      "required": [
        "x",
        "y",
        "width",
        "height"
      ]
    },
    "FormatOverride": {
      "type": "object",
      "properties": {
        "content": {
          "type": "string"
        },
        "cssClass": {
          "type": "string"
        },
        "knockout": {
          "$ref": "#/definitions/TemplateElement"
        },
        "ctaTreatment": {
          "$ref": "#/definitions/TemplateElement"
        },
        "tone": {
          "anyOf": [
            {
              "$ref": "#/definitions/TemplateElement"
            },
            {
              "type": "boolean",
              "const": false
            }
          ],
          "description": "Per-format surface override. `false` = explicitly follow the scheme base on this format (cancels a base-level tone), mirroring `knockout: false`."
        },
        "fill": {
          "anyOf": [
            {
              "$ref": "#/definitions/TemplateElement"
            },
            {
              "type": "boolean",
              "const": false
            }
          ],
          "description": "Per-format fill-follows override; `false` cancels a base-level fill."
        },
        "useBrandDefault": {
          "type": "boolean"
        },
        "useRowImage": {
          "type": "boolean"
        },
        "styles": {
          "type": "object",
          "properties": {
            "x": {
              "type": "number"
            },
            "y": {
              "type": "number"
            },
            "width": {
              "type": "number"
            },
            "height": {
              "anyOf": [
                {
                  "type": "number"
                },
                {
                  "type": "string",
                  "const": "auto"
                }
              ]
            },
            "zIndex": {
              "type": "number"
            },
            "visible": {
              "type": "boolean"
            },
            "rotation": {
              "type": "number"
            },
            "fontSize": {
              "type": "number"
            },
            "fontFamily": {
              "type": "string"
            },
            "fontWeight": {
              "type": "number"
            },
            "lineHeight": {
              "type": "number"
            },
            "letterSpacing": {
              "type": "number"
            },
            "textTransform": {
              "type": "string"
            },
            "textAlign": {
              "type": "string"
            },
            "color": {
              "type": "string"
            },
            "backgroundColor": {
              "type": "string"
            },
            "borderRadius": {
              "type": "number"
            },
            "padding": {
              "type": "number"
            },
            "paddingX": {
              "type": "number"
            },
            "paddingY": {
              "type": "number"
            },
            "opacity": {
              "type": "number"
            },
            "boxShadow": {
              "type": "string"
            },
            "shapeType": {
              "type": "string",
              "enum": [
                "rectangle",
                "circle"
              ]
            },
            "objectFit": {
              "type": "string"
            },
            "objectPositionX": {
              "type": "number"
            },
            "objectPositionY": {
              "type": "number"
            },
            "imageZoom": {
              "type": "number"
            },
            "imageOffsetX": {
              "type": "number"
            },
            "imageOffsetY": {
              "type": "number"
            },
            "overflow": {
              "type": "string"
            },
            "tintColor": {
              "type": "string"
            },
            "blur": {
              "type": "number"
            },
            "brightness": {
              "type": "number"
            }
          }
        },
        "logoForm": {
          "type": "string"
        },
        "logoTreatment": {
          "type": "string",
          "enum": [
            "on-dark",
            "on-light",
            "full-color"
          ]
        },
        "logoColor": {
          "type": "string"
        },
        "logoAssetId": {
          "type": "string"
        },
        "frameHint": {
          "type": "string"
        },
        "frameAutoFit": {
          "type": "string",
          "enum": [
            "poi",
            "none"
          ]
        },
        "framePoi": {
          "type": "object",
          "properties": {
            "x": {
              "type": "number"
            },
            "y": {
              "type": "number"
            }
          },
          "required": [
            "x",
            "y"
          ]
        },
        "constraints": {
          "$ref": "#/definitions/FormatConstraintOverride",
          "description": "Per-format override of the format-dependent text constraints. A 9:16 story holds a different amount of copy at a different size than a 16:9 banner, so the char budget and font-size band carry a per-format dimension. Shallow-merged over the element's base `constraints` by `resolveConstraintsForFormat()`. Format-independent constraint fields (guidelines, imageGuidance, contentEditable, anchors) stay base-only."
        },
        "hidden": {
          "type": "boolean",
          "description": "Soft-delete: element is skipped on this format. Distinct from `styles.visible: false` — `hidden` is a deliberate \"this element doesn't belong on this aspect ratio\" choice. The renderer treats `hidden` and `visible: false` the same way (skip), but the editor UI keeps them visually distinct (eye toggle vs. hide-from-format)."
        },
        "clusterGapBefore": {
          "type": "number",
          "description": "Per-format override of `TemplateElement.clusterGapBefore`. Absolute pixel gap before this element when laid out as a cluster member (replaces the cluster's default `gap`). Ignored when the element is the first member of its cluster."
        }
      },
      "description": "Per-format override of any element field that makes sense to vary per aspect ratio. Sparse by design — only fields the user explicitly overrode are present. Resolved via `field ?? element.field` at render time; styles shallow-merges into base."
    },
    "FormatConstraintOverride": {
      "type": "object",
      "properties": {
        "minChars": {
          "type": "number"
        },
        "maxChars": {
          "type": "number"
        },
        "idealChars": {
          "type": "number"
        },
        "maxLines": {
          "type": "number"
        },
        "fontSizeRange": {
          "type": "array",
          "items": {
            "type": "number"
          },
          "minItems": 2,
          "maxItems": 2
        },
        "idealFontSize": {
          "type": "number"
        }
      },
      "description": "The per-format subset of an element's `constraints`. Copy capacity is inherited from the base element by default because base-scope copy edits land on every aspect ratio; min/ideal/max chars only appear here when an author deliberately pins a format-specific copy budget. Layout constraints (maxLines, font-size band, target font size) commonly vary by aspect ratio. Shallow-merged over the base `element.constraints` by `resolveConstraintsForFormat()`."
    },
    "TemplateGuide": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "enum": [
            "line",
            "point"
          ]
        },
        "label": {
          "type": "string"
        },
        "description": {
          "type": "string"
        },
        "tooltipPosition": {
          "type": "string",
          "enum": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "axis": {
          "type": "string",
          "enum": [
            "horizontal",
            "vertical"
          ]
        },
        "position": {
          "type": "number"
        },
        "x": {
          "type": "number"
        },
        "y": {
          "type": "number"
        },
        "formatPositions": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "properties": {
              "position": {
                "type": "number"
              },
              "x": {
                "type": "number"
              },
              "y": {
                "type": "number"
              }
            }
          }
        },
        "appliesTo": {
          "type": "string",
          "enum": [
            "format",
            "all"
          ],
          "description": "Scope of this guide.\n- \"format\": guide exists only on the format named in `formatName`. Default for new guides.\n- \"all\": guide shows on every format. The base position (`position`/`x`/`y`) is the   default; dragging on a non-base format writes a per-format override into   `formatPositions[fmt]` so different aspect ratios can have different \"good positions\"   for the same conceptual guide. Legacy guides without this field are treated as \"all\" to preserve prior behavior."
        },
        "formatName": {
          "type": "string",
          "description": "Format this guide is scoped to when `appliesTo === \"format\"`."
        },
        "kind": {
          "type": "string",
          "enum": [
            "spatial",
            "anchor",
            "instruction",
            "ref-point",
            "poi"
          ],
          "description": "Semantic flavor of this guide. Optional; humans plant these to tell any LLM (in-app agent or external MCP caller) what this guide is for so the agent can respect authoring intent.\n- \"spatial\" — a layout guide for human eyeballing\n- \"anchor\" — elements should snap/cluster around it\n- \"instruction\" — paired with the guide's description\n- \"ref-point\" — a labeled reference position (e.g. \"logo lockup\")\n- \"poi\" — point of interest: marks where a photo's subject should   land inside an image element. Paired with `Asset.metadata.poi`,   `lib/poi-align.ts` solves for object-position so the subject lines   up with this point. Per-format by design (1×1, 9×16, etc. each   want the subject in a different place)."
        }
      },
      "required": [
        "id",
        "type",
        "label"
      ]
    },
    "CSSStyle": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        },
        "css": {
          "type": "string"
        },
        "description": {
          "type": "string"
        }
      },
      "required": [
        "name",
        "css"
      ]
    },
    "Cluster": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string"
        },
        "memberIds": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Element ids in stack order. Hidden / soft-deleted elements are skipped at repair time."
        },
        "axis": {
          "type": "string",
          "enum": [
            "vertical",
            "horizontal"
          ]
        },
        "gap": {
          "type": "number",
          "description": "Px between consecutive members. Author the intended rhythm."
        },
        "anchors": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/AnchorBinding"
          },
          "description": "Anchors that pin the cluster's bounding box to one or more guides. Each anchor binds one cluster edge (left / right / top / bottom / center-x / center-y) to a guide. Multiple anchors are evaluated in order — anchors on different axes are independent (one horizontal + one vertical guide constrains both axes), and within the same axis the later anchor wins. Empty / undefined means no anchor."
        },
        "anchorGuideId": {
          "type": "string",
          "description": "Legacy single-anchor fields. Kept for backward compat with templates authored before `anchors[]` existed. When `anchors` is set, these are ignored. When `anchors` is absent, a non-empty `anchorGuideId` + `anchorEdge` are read as a single-entry anchors list.",
          "deprecated": "Prefer `anchors`."
        },
        "anchorEdge": {
          "type": "string",
          "enum": [
            "left",
            "right",
            "top",
            "bottom",
            "center-x",
            "center-y"
          ],
          "description": "Which edge of the element snaps to the guide.\n- \"left\" / \"right\" → snap x or x+width to a vertical guide's position\n- \"top\" / \"bottom\" → snap y or y+height to a horizontal guide's position\n- \"center-x\" → snap x + width/2 to a vertical guide\n- \"center-y\" → snap y + height/2 to a horizontal guide",
          "deprecated": "Prefer `anchors`."
        },
        "formatOverrides": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "properties": {
              "gap": {
                "type": "number"
              },
              "axis": {
                "type": "string",
                "enum": [
                  "vertical",
                  "horizontal"
                ]
              },
              "anchors": {
                "type": "array",
                "items": {
                  "$ref": "#/definitions/AnchorBinding"
                }
              },
              "anchorGuideId": {
                "type": "string",
                "deprecated": "Prefer `anchors`."
              },
              "anchorEdge": {
                "type": "string",
                "enum": [
                  "left",
                  "right",
                  "top",
                  "bottom",
                  "center-x",
                  "center-y"
                ],
                "description": "Which edge of the element snaps to the guide.\n- \"left\" / \"right\" → snap x or x+width to a vertical guide's position\n- \"top\" / \"bottom\" → snap y or y+height to a horizontal guide's position\n- \"center-x\" → snap x + width/2 to a vertical guide\n- \"center-y\" → snap y + height/2 to a horizontal guide",
                "deprecated": "Prefer `anchors`."
              }
            }
          },
          "description": "Optional per-format overrides for gap, axis, or anchors. Sparse. Spelled out (identical to Partial<Pick<Cluster, ...>>) because the JSON-schema generator can't resolve a self-referencing mapped type — `pnpm docs:schema` reads this shape into the public contract."
        }
      },
      "required": [
        "id",
        "memberIds",
        "axis",
        "gap"
      ],
      "description": "Authorial grouping of elements that should be laid out together — typically headline + subheading + CTA. The layout-repair pipeline stacks members along `axis` with `gap` and anchors the cluster as a whole to `anchorGuideId` using `anchorEdge`. Cluster definition survives copy swaps and font changes: it's intent, not derived geometry."
    },
    "Group": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string"
        },
        "memberIds": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Element IDs whose authored offsets are frozen relative to each other."
        },
        "name": {
          "type": "string",
          "description": "Human-readable label shown in the layers panel. Optional."
        },
        "reusable": {
          "type": "boolean",
          "description": "When true, this group is the canonical clone-source for the agent's `duplicate_group` op (and gets a subtle accent outline in the editor). Lets the human author signal \"this row is the prototype\" so the agent doesn't have to guess. Optional."
        },
        "clusterGapBefore": {
          "type": "number",
          "description": "Gap (px) before this group when it appears as a cluster member. Mirrors `TemplateElement.clusterGapBefore` so authors can dial the rhythm between rows / cards / list items even when the unit is a frozen group. Replaces `cluster.gap` for the spacing immediately before this group; first-member position is ignored."
        },
        "clusterGapBeforeFormats": {
          "type": "object",
          "additionalProperties": {
            "type": "number"
          },
          "description": "Per-format overrides for `clusterGapBefore`, keyed by format name. Sparse — only entries the user has explicitly set. Lets a 9:16 list use a bigger gap-between-rows than the 1:1 / 4:5 layouts without forking the cluster definition."
        }
      },
      "required": [
        "id",
        "memberIds"
      ],
      "description": "Frozen sub-arrangement of elements that exposes a single bounding box to cluster-layout. Unlike a Cluster (which has its own stacking rule with gap + axis), a Group preserves the authored relative offsets of its members — moving the group translates every member by the same delta. Used so a `[checkbox, text]` row can be addressed as one unit inside a vertical \"list\" cluster.\n\nSingle level of containment only — `memberIds` are element IDs (no nested groups). Membership invariants:  - Each element appears in at most one group's `memberIds`.  - Each group ID appears in at most one cluster's `memberIds`."
    },
    "BrandFitDocConfig": {
      "type": "object",
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Master switch. `false` ⇒ the entire BrandFit engine is suppressed for this doc — no rules fire, declared clusters stop positioning their members, anchors stop snapping. Per-rule settings in `rules` are preserved so flipping the switch back on restores the full configuration. `undefined` / absent behaves as `true`."
        },
        "rules": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/definitions/BrandFitRuleConfig"
          }
        }
      },
      "description": "Top-level BrandFit doc config. `rules` is keyed by ruleId (the same id Rule.id carries in code). Future doc-wide knobs (e.g. global pause) can slot in alongside without breaking the shape."
    },
    "BrandFitRuleConfig": {
      "type": "object",
      "properties": {
        "enabled": {
          "type": "boolean"
        },
        "auto": {
          "type": "boolean",
          "description": "True ⇒ rule runs in the editor's Auto path (on edits, against live DOM). False ⇒ rule only runs when the BrandFit button is clicked. Absent ⇒ inherit Rule.defaultAuto."
        },
        "config": {
          "type": "object",
          "additionalProperties": {
            "type": [
              "number",
              "boolean",
              "string"
            ]
          }
        },
        "formatOverrides": {
          "type": "object",
          "additionalProperties": {
            "type": "object",
            "properties": {
              "enabled": {
                "type": "boolean"
              },
              "auto": {
                "type": "boolean"
              },
              "config": {
                "type": "object",
                "additionalProperties": {
                  "type": [
                    "number",
                    "boolean",
                    "string"
                  ]
                }
              }
            }
          }
        }
      },
      "description": "Per-rule config carried on a doc. Either a field is set (explicit override) or absent (inherit the rule's default). `formatOverrides` mirrors the same shape per format name; merged on top of the template-level entry at resolve time."
    },
    "Creative": {
      "type": "object",
      "properties": {
        "kind": {
          "type": "string",
          "enum": [
            "branded",
            "concept"
          ],
          "description": "\"branded\" (default) — typography and color cascade from the brand (typeStyles + colorRoles, keyed by element.role + variant). \"concept\" — the brand cascade is bypassed. The document carries its own `typeStyles` + `roleColors` instead, and the cascade reads from there. Use for fake-UI / parody layouts (Notes, iMessage, Tinder, leaked PDF, etc.) where the joke depends on looking like the thing being parodied. Brand assets (logo, useBrandDefault, var(--brand-*)) still opt in."
        },
        "variant": {
          "type": "string",
          "description": "Which color scheme this document renders in — a scheme id, not just a tonal toggle. The built-ins \"dark\" and \"light\" resolve straight from the brand's `colorRoles` (*OnDark / *OnLight). Any other value is the id of a custom  {@link  ColorScheme }  in `brand.colorSchemes`, resolved at render time via `resolveSchemeContext()` (lib/color-schemes.ts) into a tonal base + a derived colorRoles. Defaults to \"dark\". Unused for `kind: \"concept\"`. (Field kept named `variant` for storage/API continuity; surfaced as \"color scheme\" everywhere user- and agent-facing.)"
        },
        "themeId": {
          "type": "string",
          "description": "Which  {@link  Theme }  this document resolves against — a custom theme id in `brand.themes`, or undefined/\"default\" for the brand's default theme (the brand root styling). The theme swaps colorRoles + typeStyles + baseStyles before the color-scheme cascade runs (`applyTheme` in lib/themes.ts), so a theme changes the full look (fonts, button shape, colors) while `variant` still recolors on top. Unused for `kind: \"concept\"`."
        },
        "typeStyles": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TypeStyle"
          },
          "description": "Per-role typography presets carried by the document itself. Mirrors `BrandConfig.typeStyles`. For concept templates this is the only source of typography (brand cascade is bypassed). Resetting a typography field on an element pulls it back from here, and new text elements seed defaults from the matching role here.\n\nBranded templates can also set this to override brand typography locally, but it's optional and unusual — most branded templates just use the brand's typeStyles directly."
        },
        "roleColors": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          },
          "description": "Per-role color map carried by the document itself. Flat (role → color hex/CSS) since concept templates don't use the brand's variant-suffixed `colorRoles`. The cascade reads from here for concept templates so resetting a color field on an element brings back the document's intended color."
        },
        "sourceSystemTemplateId": {
          "type": "string",
          "description": "Non-authoritative back-reference to the system template this document was instantiated from. Set on brand templates at instantiation time and propagated to ads at clone time. Used by the editor's asset picker to default-filter the Stock tab to the source template's bundled assets. `null`/missing means \"no source\" (e.g. brand templates created from scratch). Editing the system template does NOT propagate to documents that reference it — the pointer is purely for discovery."
        },
        "agentInstructions": {
          "type": "string",
          "description": "Long-form LLM-facing briefing on how to use this template. Multi- paragraph copywriting playbook with structural intent, do/don't patterns, and ideally worked examples per industry. Distinct from the short `description` which is human-facing card copy.\n\nConsumed by the workflow pipeline's `editAdAgent` (highest leverage) and `selectTemplates` so the model understands what the template is FOR, not just what it looks like. Propagates from system template → brand template (instantiate) → ad (materialize) so each snapshot carries its own briefing.\n\nAuthored on the template detail page via a dedicated modal — never shown on cards / catalogs."
        },
        "formats": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/AdFormat"
          }
        },
        "elements": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TemplateElement"
          }
        },
        "guides": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/TemplateGuide"
          }
        },
        "styles": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/CSSStyle"
          }
        },
        "clusters": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/Cluster"
          },
          "description": "Authorial layout groupings. Each cluster declares \"these elements belong together — stack them with this gap, anchor as a group to this guide.\" The deterministic layout-repair pipeline applies them after every content change so headline/subheading/CTA rhythm survives copy edits. Empty/missing means no group semantics for this template; repair falls back to element-level anchors + the brand-fit cascade.\n\nA cluster's `memberIds` may reference either elements OR groups (single level of containment) — the cluster-layout rule resolves each id to either an element rect or a group's union bbox."
        },
        "groups": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/Group"
          },
          "description": "Frozen sub-arrangements that expose a single bounding box. Members' authored offsets are preserved; moving the group translates every member by the same delta. Used so a `[checkbox, text]` row can be the unit a cluster stacks (cluster gap → row spacing) and so the agent can duplicate the row as one thing.\n\nSingle level of containment only — a group's `memberIds` are element IDs (no nested groups). Each element belongs to at most one group. Each group belongs to at most one cluster."
        },
        "brandFit": {
          "$ref": "#/definitions/BrandFitDocConfig",
          "description": "Per-document BrandFit configuration. Each rule (anchor-snap, cluster- layout, overflow, widow, etc.) defaults to a sensible enabled/auto state declared in code; this map overrides those defaults per rule, with optional per-format overrides. A rule with no entry here uses its default. Persisted with the doc; cloned through instantiate + materialize via JSON deep-clone."
        },
        "version": {
          "type": "number",
          "description": "Optimistic-concurrency counter. A dedicated DB column (NOT part of the doc JSONB) that every save bumps. Present on docs read from the store; `saveTemplate` / `saveCreative` compare-and-set against it and throw  {@link  DocVersionConflictError  }  when another writer got there first, so callers re-read and re-apply instead of silently clobbering. Undefined on freshly constructed docs (create paths insert at 1)."
        },
        "id": {
          "type": "string"
        },
        "brandId": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "templateId": {
          "type": "string",
          "description": "Non-authoritative origin pointer — the brand template this creative was cloned from. Editing that template does NOT propagate here."
        },
        "collection": {
          "type": "string",
          "description": "Legacy free-text collection label — superseded by `collectionId` (dual-written until the Phase B column drop; API responses keep a derived name field)."
        },
        "collectionId": {
          "type": "string",
          "description": "The Collection this creative is FILED in. Provenance-primary: stamped at creation from the producing run / manual create; board placement never re-files, explicit moves do. Undefined = Unfiled."
        },
        "tags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "status": {
          "$ref": "#/definitions/CreativeStatus"
        },
        "workflowId": {
          "type": "string"
        },
        "workflowReasoning": {
          "type": "string"
        },
        "workflowStatus": {
          "type": "string",
          "enum": [
            "pending",
            "editing",
            "done",
            "failed"
          ]
        },
        "workflowError": {
          "type": "string"
        },
        "slotKey": {
          "type": "string",
          "description": "The SLOT this creative fills — the deterministic name of the promise it was materialized into (lib/factory/slots.ts). A canvas wire drawn against a not-yet-existing creative names this key; the engine resolves it to this row once it exists. Undefined when the identity wasn't determinable pre-run (an auto-matched original) or the creative belongs to no run. Persisted column (creatives.slot_key)."
        },
        "boardId": {
          "type": "string",
          "description": "The board this creative is HELD on for review (its holding place). Produced by one run (workflowId) but reviewed on a board that can span runs."
        },
        "language": {
          "type": "string",
          "description": "Language code stamped by a Translate node (registry code from lib/languages.ts, e.g. \"sv\"). Lives in the document JSONB (no column — reads spread it back); a `lang:<code>` tag is dual-stamped for filtering. Route's \"language\" rule source matches on this — non-tri-state like \"template\": written at mint, so a creative without one is a MISMATCH, never pending."
        },
        "row": {
          "$ref": "#/definitions/SetRow",
          "description": "The subject this creative is FOR, stamped by a \"For each\" node at mint (see  {@link  SetRow } ). Lives in the document JSONB like `language` — no column. Template bindings read it back as `row.<key>` at render time, and the copy agent takes it as context in `adapt` mode.\n\nCopied INLINE rather than referenced, deliberately: deleting the Set (or the source asset) must never make a shipped creative unreadable, and a row edited after the fact must never silently change finished work."
        },
        "workflowScore": {
          "type": "number",
          "description": "Derived from annotations at read time — not a DB column."
        },
        "workflowCritique": {
          "type": "string",
          "description": "Derived from annotations at read time — not a DB column."
        },
        "createdAt": {
          "type": "string"
        },
        "updatedAt": {
          "type": "string"
        }
      },
      "required": [
        "brandId",
        "createdAt",
        "elements",
        "formats",
        "id",
        "name",
        "status",
        "updatedAt"
      ],
      "description": "A creative — one finished artifact: a full Braaand document cloned from a brand template. Organized by its Collection (the first-class container grouping everything produced together — see lib/collections.ts) plus `tags` / `status` metadata; the brand gallery itself stays a flat list. Replaces `Ad`."
    },
    "CreativeStatus": {
      "type": "string",
      "enum": [
        "draft",
        "in-review",
        "approved",
        "archived"
      ]
    },
    "SetRow": {
      "type": "object",
      "properties": {
        "id": {
          "type": "string",
          "description": "Stable within its set. CLOSES the slot key (`…/s:<rowId>@<node>`), so reordering or renaming rows never re-keys an existing promise — the same reasoning as a Translate version keying on its language code rather than an ordinal. Asset-backed rows use the asset id."
        },
        "label": {
          "type": "string",
          "description": "What the canvas, the creative name, and the person resolver show — \"Frida Karlsson\", \"Stockholm\"."
        },
        "fields": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          },
          "description": "Freeform per-row data, bindable as `row.<key>`. Asset-backed rows carry the flattened  {@link  PersonMetadata }  (fullName, title, department, …); data rows carry whatever the Set's columns are (region, deadline, …). Deliberately open: a future Research node writes its findings here with no schema change."
        },
        "assetId": {
          "type": "string",
          "description": "The row's image. Written into the element marked `useRowImage: true` when this row's creative is minted. Absent for pure data rows."
        }
      },
      "required": [
        "id",
        "label",
        "fields"
      ],
      "description": "One subject in a  {@link  BrandSet }  — a candidate, a municipality, a segment, a product. The unit a \"For each\" node iterates: it mints one creative per row and stamps the row onto that creative (`Creative.row`), where template bindings read it back as `row.<key>` at render time.\n\nRows come from two sources and this shape is what makes them one thing: an ASSET-backed row (a headshot, fields flattened from `metadata.person`) and a DATA row (a stored Set) are indistinguishable downstream. See `resolveSetRows` in lib/factory/set-rows.ts."
    }
  }
}
