banner_studio/RESOLVED_FEED.md
Simeon Schecter ccbdb47162 Day 5+6 of the vertical slice: multi-size + per-row strips
Expands the slice from a single 300x250 banner to four IAB sizes
(300x600, 300x250, 728x90, 160x600) driven by a designer-authored
TypeSystem and a per-row strip review surface.

Layout engine
- TypeSystem with role-based typography (headline/subheadline/cta/legal)
  and piecewise size-class derivation: half_page / rectangle /
  leaderboard / skyscraper / mobile_banner.
- resolveLayout now derives per-size font/leading from the role +
  artboard size, then clamps to a legibility floor and emits a
  constraint_signal when copy does not fit at the floor.
- Four reference templates with character constraints per size.

AI pipeline (Shape B)
- One extract + one generate per feed row; generate returns per-size
  copy keyed by artboard_id plus a shared rationale block.
- Constraint-signal retry: orchestrator tightens per-(artboard, field)
  limits and re-calls generate before giving up.
- orchestrateRow returns specs[] + rationale + constraint_signals.

Review UI
- /review renders one strip per feed row, all four sizes side-by-side
  at true pixel dimensions, synced on a single GSAP master timeline.
- AiReasoningDrawer shows a per-size copy table, shared rationale, and
  any constraint signals that fired.
- /api/generate response grouped by row; /api/export accepts the same
  shape and writes exports/row-N/artboard_id.zip.

Render worker
- render-to-zip / render-many accept optional subdir + filename
  overrides so multi-size exports can be grouped by feed row.

Docs
- VERTICAL_SLICE and BUILD_SEQUENCE updated for the multi-size scope.
- RESOLVED_FEED.md documents the V1 Resolved Creative Feed proposal.
- SLICE_DEVIATIONS.md records where the slice diverges from V1.

Tests: 56 pass (28 layout-engine + 14 api-lib + 14 render-worker).
Web app: tsc clean, next build succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 14:22:26 -04:00

20 KiB
Raw Permalink Blame History

RESOLVED_FEED.md

Status: V1 architecture proposal. Not implemented in the vertical slice. The slice persists per-size editorial decisions in BannerSpec.ai_reasoning as a forward-pointer to this model (see SLICE_DEVIATIONS.md).

A proposal for the V1 version/override architecture. Replaces — or significantly reshapes — the override-merge layer described in ARCHITECTURE.md Part 7. The load-bearing claim: copy belongs in a sparse, per-product-per-size feed, not in a patch layer applied after generation.


The Two-Feed Model

The system manages two feeds, not one.

Inbound Feed. What the client provides. One row per product. Flat columns (headline, subheadline, hero_image_url, cta_text, click_url, …). Authored by the client or by humans inside the agency. Never edited by the system. Refreshes on CSV upload or live API pull.

Resolved Creative Feed. What the system manages internally. One cell per product per size per copy field. Sparse — cells exist only where variation is needed. Authored by the AI (most cells), the constraint signal (per-size deviations), or humans (review-UI edits). The render worker reads from this feed. The review UI reads from this feed. The export pipeline reads from this feed.

The user only ever interacts with the inbound feed (upload) or the review UI (per-banner). The resolved creative feed is the engine room — visible in audit views, never edited as a spreadsheet.

INBOUND FEED                      RESOLVED CREATIVE FEED
─────────────────────────         ────────────────────────────────────────
client artifact                   system artifact
one row per product               one cell per product per size per field
flat, dense                       sparse — cells only where needed
human-authored only               AI + constraint + human
refresh = overwrite (with locks)  refresh = orchestrated regeneration

Cell Types

A copy cell in the resolved feed has a source indicating how it came to be. The source determines who can write to it and what happens on regeneration.

1. Inbound cell. Equal to the inbound feed value for this product + field. No size-specific variation. Read-only from the system; only changes on external feed refresh.

2. Generated baseline cell. Written by the AI on first pass against the reference artboard's constraints and the brand voice. This is the "house version" of the copy — what would render at the reference size and at any non-reference size that didn't trigger a constraint signal. One per product + field.

3. Per-size cell. Created in exactly two situations:

  • The layout engine emitted a constraint_signal for this product at this size, and the AI rewrote shorter copy to fit.
  • A human edited the banner at this size in the review UI.

Read order at render time. For a given product, size, and field, the renderer reads in priority order:

per-size cell  →  generated baseline cell  →  inbound cell

If a per-size cell exists, it wins. Otherwise the generated baseline. Otherwise the inbound value (which means the AI's baseline pass was a no-op for this field — typically because the inbound value already met every constraint and the brand voice didn't require a rewrite).

Sparseness in practice. A typical 5-product, 4-size campaign has 5 inbound rows. The resolved feed adds ~15 generated baseline cells (3 fields × 5 products, give or take, since some fields may not need rewriting). Per-size cells appear only where the leaderboard couldn't fit the baseline headline, or the producer adjusted something specific. Total cells in the resolved feed for the campaign: maybe 2540 across what would be 60 dense cells (5 × 4 × 3). Sparseness ratio matters because it's the audit-trail signal — every per-size cell represents a real editorial decision.


How Cells Are Created

NEW INBOUND ROW
  → 1 inbound cell per field

AI BASELINE PASS (Generate agent, reference-size constraints)
  → 1 generated baseline cell per field where the AI rewrote
  → No cell where the inbound value already passes validation

CONSTRAINT SIGNAL FROM LAYOUT ENGINE
  Layout engine: "728x90 headline doesn't fit at 18px floor, max 32 chars"
  Orchestrator re-invokes Generate with tightened limit for that size
  → 1 per-size cell at (product, 728x90, headline)

HUMAN EDIT IN REVIEW UI
  Producer changes headline on the 728x90 banner for product 3
  → 1 per-size cell at (product 3, 728x90, headline)
  → Cell flagged human_authored: true (see Lock Mechanism below)

If a producer edits at the reference size (300x600), the system has a choice: update the generated baseline cell, or create a per-size cell at the reference size. The right answer is update the baseline cell and mark it human_authored: true, so the edit propagates to any non-reference size that was reading the baseline. This is the only place the cell model gets subtle; the UI should make the propagation visible ("this edit will apply to all sizes except 728x90, where you previously customized the headline").


The Override Problem, Reframed

The current ARCHITECTURE.md Part 7 model:

AI generates →  human edits  →  override patch stored separately
Regenerate   →  new generation + patch re-applied
                 ↑ conflicts possible at this merge step

The merge step is where complexity lives. The patch is a field_path + value; on regeneration, the patch is re-applied to the new spec; if the field shape changed (e.g., the AI removed a layer the override referenced), the merge algorithm has to resolve the conflict.

The resolved-feed model:

AI generates →  writes baseline + per-size cells
Human edits  →  writes per-size cell, sets human_authored: true
Regenerate   →  reads resolved feed; AI only rewrites un-locked cells
                 ↑ no merge — edited cells were already the canonical source

There is no conflict because there is no after-the-fact patch application. The edit is the cell value. Regeneration reads the cell; the AI is invoked only for cells without human_authored: true.

Important honest scope limit. This collapses the override problem for copy. Non-copy overrides — visual properties on a layer, hero focal point adjustments, GSAP timing changes, layer visibility per artboard, asset variant selection — do not naturally live as cells in a copy feed. Those still require the existing override layer (or some shrunken version of it). The resolved feed eliminates the largest, highest-conflict surface of the override system; it does not eliminate it entirely.

V1 should ship with both: resolved feed for copy, smaller patch layer for non-copy. Future cleanup can examine whether non-copy edits can be folded into a generalized cell model (likely yes for layer-level properties keyed by layer_id; probably no for cross-cutting decisions like animation preset selection).


Lock Mechanism

Two independent flags on every cell. Default values matter; both default to the producer-protective setting on human edit.

human_authored — true when a human authored or edited this cell.

  • Effect on regeneration: AI does not rewrite this cell.
  • Default on human edit: true.
  • Producer can explicitly reset to false ("let the AI try again").

external_pinned — true when this cell should resist inbound feed refresh.

  • Effect on external feed refresh: inbound value does not overwrite this cell.
  • Default on human edit: true.
  • Producer can relax to false if they want the new product price from the next feed pull but want to keep their headline edit.

The combinations:

human_authored external_pinned Behavior
false false AI may rewrite; feed refresh may overwrite
true false AI does not rewrite; feed refresh may overwrite
false true AI may rewrite; feed refresh does not overwrite
true true AI does not rewrite; feed refresh does not overwrite

The two flags are independent because they answer different questions ("who controls editorial decisions?" vs "is this synced with the source of record?"). Conflating them — as the original write-up did with a single "locked" flag — loses real producer leverage.


Revert / Delete Semantics

A per-size cell exists because something deviated from the baseline. When a producer wants to revert ("let the AI decide again, or fall back to baseline"), the cell is deleted, not edited to match the baseline.

Deletion drops back through the source chain:

delete per-size cell  →  reads generated baseline cell (if exists)
delete baseline cell  →  reads inbound cell
delete inbound cell   →  impossible (inbound feed is the floor)

The UI shows a single "revert to system" action per cell. Producers think in terms of the rendered banner, not the cell tree, so the action is phrased "let the AI/system decide this again." Under the hood it's a cell deletion.

Open question for V1. Should deletion also clear human_authored and external_pinned on the underlying baseline cell if that cell exists? Probably yes — if the producer is reverting, they're signaling they no longer want the lock. Worth confirming with producers before locking the behavior.


External Feed Refresh

When a new CSV is uploaded or the live feed updates, the system:

  1. Diffs new inbound rows against existing inbound cells.

  2. For each changed cell:

    • If the resolved feed cell at this product + field is external_pinned: true anywhere (baseline or any per-size), do nothing.
    • Otherwise, update the inbound cell value.
    • If a baseline cell exists for this field, mark it stale and queue regeneration. (The new inbound copy may invalidate the AI's baseline rewrite.)
    • If per-size cells exist for this field and are not pinned, mark them stale and queue regeneration.
  3. Surface the stale set in the review UI so the producer chooses when to trigger regeneration.

Note on stale state. Stale cells render with their current value (no banner breaks because of a feed refresh). They're flagged in the UI as "needs regeneration." Regeneration runs only the affected cells, not the whole campaign. This is what makes "production gets faster with every campaign" real — most refreshes touch a small set of cells, not all of them.


Brief Mode

This model applies in brief mode with one structural difference: there is no inbound feed.

  • No inbound cells exist.
  • Generated baseline cells are written from the brief on first pass.
  • Per-size cells are created from constraint signals or human edits, same as feed mode.
  • external_pinned is meaningless and becomes a no-op (no inbound source to resist). UI hides the flag in brief mode.
  • human_authored does the same job it does in feed mode.

The override-preservation backstop (the smaller patch layer mentioned above) is more load-bearing in brief mode for non-copy overrides, but copy edits still write to cells.


What This Unlocks Beyond Solving the Override Problem

Auditable creative history. The resolved feed is the creative record. Every campaign leaves behind a structured document of what shipped at every size for every product, with sources attributed (AI baseline, constraint signal, human edit) and timestamps. Compliance, brand audits, and "what did we run last Black Friday?" all become queries against the feed.

Compounding speed across campaigns. Next campaign, same products: the producer starts from the previous campaign's resolved feed as a baseline. The brief writer marks which products are unchanged. For unchanged products, the system reuses cells verbatim (no AI calls). For changed products, only those rows regenerate. For campaign-level changes (new brand voice, new character constraints), all human_authored: false cells regenerate.

This speedup is a feature to build, not a property that emerges. The resolved feed makes it possible; the campaign-rerun flow has to be designed and shipped. Worth a dedicated build phase in V1.

A real surface for editorial review. Right now, "see what the AI decided" is a derived view assembled from logs and ai_reasoning fields. With the resolved feed, it's a table: product × size × field, with each cell showing source, value, timestamp, and any constraint signal that drove the decision. Reviewing a campaign becomes a structured pass through this table rather than clicking through 25 banner previews.

Cleaner V2 hooks. Multi-language: another dimension on the cell key (product × size × field × locale). Multi-market: same shape. The cell model generalizes more cleanly than the patch model does, because patches are inherently anchored to a single spec at a single point in time.


What This Doesn't Solve

Non-copy overrides. As above. Visual, layout, animation, and asset selection edits still need the patch layer (or a generalized cell model keyed by layer_id + property_path, which is a V2 question).

Cross-product editorial coherence. A campaign may need its headlines to feel consistent across products in tone and structure. The cell model doesn't encode this constraint; the Generate agent's prompt does, and the producer reviews. This is fine — but worth not claiming the resolved feed solves something it doesn't.

Inbound feed schema changes. When the client adds, drops, or renames columns, the resolved feed doesn't automatically know what to do. Adds are easy (new field, no cells yet, AI generates on next pass). Drops are trickier — historical cells for the dropped column should probably be preserved, marked stale, and hidden from the active campaign view but retained for audit. Renames are the hardest and likely require explicit producer intent ("treat subheadline as the new subhead"). V1 should handle adds and drops; renames are a V2 capability.


Schema Sketch

Not a final V1 schema — a sketch to make the model concrete.

-- The two cell roots.

inbound_cells (
  product_id TEXT,
  field TEXT,             -- 'headline', 'subheadline', 'cta_text', …
  value TEXT,
  source TEXT,            -- 'csv_upload' | 'live_feed' | 'manual'
  updated_at TIMESTAMP,
  PRIMARY KEY (product_id, field)
)

resolved_cells (
  campaign_id TEXT,
  product_id TEXT,
  size_id TEXT NULL,      -- NULL for generated baseline; '300x600', '728x90', … for per-size
  field TEXT,
  value TEXT,
  cell_source TEXT,       -- 'inbound' | 'generated_baseline' | 'constraint_emitted' | 'human_authored'
  human_authored BOOLEAN DEFAULT FALSE,
  external_pinned BOOLEAN DEFAULT FALSE,
  constraint_signal JSONB NULL,   -- when source is constraint_emitted
  authored_at TIMESTAMP,
  authored_by TEXT NULL,  -- user id when human, AI prompt version when AI
  stale BOOLEAN DEFAULT FALSE,
  PRIMARY KEY (campaign_id, product_id, size_id, field)
)

Notes on the schema:

  • size_id IS NULL marks the generated baseline (or absence). Modeling baselines as a size_id = NULL row is cleaner than a separate table because the read query for "give me the value for (campaign, product, size, field)" is one query with a coalesce on size_id.
  • cell_source is the origin of the value; human_authored is a behavior flag. They're related but not the same — a human-authored cell has cell_source = 'human_authored' and human_authored = true, but a baseline cell that a human edited becomes cell_source = 'human_authored' (the previous AI baseline is overwritten) with human_authored = true. The flag is what drives regeneration logic; the source is what drives audit views.
  • constraint_signal carries the layout-engine emission for cells created by the constraint loop. This is exactly the data structure the slice's layout engine already produces.

Relationship to the Slice

The slice does not implement the resolved feed. It runs end-to-end in one session: CSV in → BannerSpec out → render. There is no persistence, no campaign reruns, no human editing.

The slice does, however, produce data that maps directly to the resolved feed model:

  • Per-row constraint signals. When the layout engine emits a constraint_signal and the orchestrator retries Generate with a tightened limit, the resulting copy is conceptually a per-size cell with cell_source = 'constraint_emitted'. In the slice, this lives in the resolved BannerSpec and the ai_reasoning field.

  • AI baseline reasoning. The Generate agent's per-size output is the generated baseline + per-size cell set. The slice surfaces these in the AI reasoning panel.

For the slice's BannerSpec.ai_reasoning, a per_size_decisions field captures this as a forward-pointer:

ai_reasoning: {
  asset_selection: string;
  copy_rationale: string;
  variant_selection: string;
  animation_rationale: string;
  per_size_decisions?: Array<{
    artboard_id: string;
    role: 'headline' | 'subheadline' | 'cta' | 'legal';
    reason: 'baseline' | 'constraint_emitted';
    constraint_signal?: {
      max_chars_at_floor: number;
      derived_font_size: number;
      floor_font_size: number;
    };
    decided_at: string;
  }>;
}

This is the audit-trail data the future resolved feed would carry, captured in the slice as a flat array on the spec. Cheap to add, narratively powerful for the demo ("here are the editorial decisions the AI made per size, with reasons"), and a clean migration path to the V1 cell model.


V1 Build Path

Rough sequencing for V1, not committed:

  1. Schema + storage. Implement inbound_cells and resolved_cells per the sketch above. Postgres + JSONB columns where appropriate.
  2. Read path. Implement resolveCellValue(campaign, product, size, field) as the canonical read. Render worker, review UI, export pipeline all use it.
  3. Write path — AI. Generate agent writes baseline + per-size cells. Constraint-signal retry writes cell_source = 'constraint_emitted'.
  4. Write path — human. Review UI inline edit writes cell_source = 'human_authored' with human_authored = true by default.
  5. Refresh path. Inbound feed refresh diffs against inbound_cells, respects external_pinned, marks affected resolved cells stale.
  6. Revert / delete. UI action for "revert to system" deletes the appropriate cell and recomputes downstream reads.
  7. Campaign rerun. Brief writer flow that diffs new brief against previous campaign's resolved feed, marks unchanged products, regenerates only the changed set.

Steps 14 are the minimum to ship copy editing in V1. Steps 57 are the compounding-speed payoff and can land in subsequent V1 milestones.


Open Questions for V1

  • Cell deletion and lock interaction. When a per-size cell is deleted, should the underlying baseline cell's human_authored flag be cleared if it was true? Probably yes; worth confirming with producers.
  • Multi-campaign sharing. Should the resolved feed be scoped per campaign or per client? Per campaign is simpler; per client enables cross-campaign reuse but complicates lock semantics.
  • Audit retention. How long are deleted cells kept? Forever (soft delete with deleted_at) is the conservative answer; cost may justify a TTL eventually.
  • Conflict between concurrent edits. Two producers editing the same cell. The patch model already had this problem; the cell model has it too. Optimistic locking with last-write-wins + audit log is probably enough for V1.
  • Generalizing to non-copy properties. Whether to extend the cell model to layer-level visual properties in V2, eliminating the residual patch layer entirely.

One-Line Summary

Copy lives in a sparse resolved feed — one cell per product per size per field, only created on need. AI writes baseline and per-size deviations; humans edit cells directly; regeneration reads from the feed, so copy edits survive without an override-merge layer. Non-copy overrides — visual, layout, animation — remain on a smaller patch layer.